<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Software Engineering |</title><link>https://kristianeschenburg.netlify.app/category/software-engineering/</link><atom:link href="https://kristianeschenburg.netlify.app/category/software-engineering/index.xml" rel="self" type="application/rss+xml"/><description>Software Engineering</description><generator>Source Themes Academic (https://sourcethemes.com/academic/)</generator><language>en-us</language><lastBuildDate>Tue, 27 Jan 2026 09:00:00 -0800</lastBuildDate><image><url>https://kristianeschenburg.netlify.app/img/Bayes.jpg</url><title>Software Engineering</title><link>https://kristianeschenburg.netlify.app/category/software-engineering/</link></image><item><title>Correlation IDs and Request Lineage Across Services</title><link>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</link><pubDate>Tue, 27 Jan 2026 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/request-correlation-middleware/</guid><description>&lt;p>We are a pretty small team, yet we still have a pretty large and expansive suite of services, dashboards, and backend data systems. When the number of services was small, auditting and logging was quite easy, but over the last few years, it&amp;rsquo;s become quite a chore. Imagine the following scenario:&lt;/p>
&lt;p>A scientist tells you the page on the cell culture analysis dashboard was slow this morning.&lt;/p>
&lt;p>If you can&amp;rsquo;t answer at least one of the following, you might find this post helpful:&lt;/p>
&lt;ol>
&lt;li>Which page?&lt;/li>
&lt;li>What do they mean &amp;ldquo;slow&amp;rdquo;?&lt;/li>
&lt;li>Which user?&lt;/li>
&lt;li>When?&lt;/li>
&lt;li>What endpoint and what parameters?&lt;/li>
&lt;/ol>
&lt;p>The dashboard they&amp;rsquo;re referring to called some backend API, that API called two more, and one of those queried the LIMS system directly. That&amp;rsquo;s multiple services and log streams, and I&amp;rsquo;m willing to bet that not one line in any of them tells you which entries belong to that user&amp;rsquo;s click.&lt;/p>
&lt;p>We face this issue in a variety of places in our data platform. I don&amp;rsquo;t mean logging in the general sense of &lt;code>import logging&lt;/code>, but the narrower scope of how a request identifies itself as it moves between all of our services, so that afterwards we can put the pieces back in order and say which step was slow, or which one returned the 403 error.&lt;/p>
&lt;p>I built something that I&amp;rsquo;ve been slowly deploying and incorporating into most of our services and dashboards. Three pieces do the work: a request-scoped context, middleware that fills it in, and a handful of headers forwarded on every outbound call. It pairs with
&lt;a href="https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/">the previous post&lt;/a> on service-to-service auth, since knowing &lt;em>who&lt;/em> called and when is half the battle.&lt;/p>
&lt;hr>
&lt;h2 id="what-i-wanted-every-line-to-carry">What I wanted every line to carry&lt;/h2>
&lt;p>My goal for this tooling was that any single &amp;ldquo;typical&amp;rdquo; log line, anywhere in the system, can answer which request, which user, which service, which endpoint, how long, and what happened. This ends up being a fixed set of fields stamped onto every record key:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Field&lt;/th>
&lt;th>Example&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>request_id&lt;/code>&lt;/td>
&lt;td>&lt;code>req-474c2493-...&lt;/code>&lt;/td>
&lt;td>Ties every line from one request together, across services&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>session_id&lt;/code>&lt;/td>
&lt;td>&lt;code>ses-a55813da-...&lt;/code>&lt;/td>
&lt;td>Ties multiple requests from one browser session together&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>user_id&lt;/code>&lt;/td>
&lt;td>&lt;code>user@example.com&lt;/code>&lt;/td>
&lt;td>Who was actually doing this&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service&lt;/code>&lt;/td>
&lt;td>&lt;code>lims-adapter&lt;/code>&lt;/td>
&lt;td>Which service emitted the line&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>requesting_service&lt;/code>&lt;/td>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>Which service called &lt;em>this&lt;/em> one&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>method&lt;/code> / &lt;code>path&lt;/code>&lt;/td>
&lt;td>&lt;code>GET&lt;/code> &lt;code>/samples/S-123/molecule&lt;/code>&lt;/td>
&lt;td>Which endpoint&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>client&lt;/code>&lt;/td>
&lt;td>&lt;code>10.0.5.233&lt;/code>&lt;/td>
&lt;td>Source IP&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>status_code&lt;/code>&lt;/td>
&lt;td>&lt;code>403&lt;/code>&lt;/td>
&lt;td>What happened&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>duration_s&lt;/code>&lt;/td>
&lt;td>&lt;code>1.284&lt;/code>&lt;/td>
&lt;td>How long it took&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>event&lt;/code>&lt;/td>
&lt;td>&lt;code>request_end&lt;/code>&lt;/td>
&lt;td>Machine-readable label for the kind of line&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>From my perspective, &lt;code>request_id&lt;/code> and &lt;code>session_id&lt;/code> are the most helpful. A request ID covers one call to a service and everything it fans out into. A session ID covers everything one person did in one sitting. When someone says &amp;ldquo;it was slow this morning&amp;rdquo;, you find their session, then look at which requests inside it were slow.&lt;/p>
&lt;hr>
&lt;h2 id="how-the-context-travels">How the Context Travels&lt;/h2>
&lt;p>There are two mechanisms for how context travels within and between requests, one inside a process and one between processes.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Browser(Browser)
Dash[Dashboard]
ApiA[Service A]
ApiB[Service B]
Lims[LIMS]
Logs[Log store]
Browser --&amp;gt;|&amp;#34;click&amp;#34;| Dash
Dash --&amp;gt;|&amp;#34;X-Request-ID, X-Session-ID, X-User-ID, X-Requesting-Service: dashboard&amp;#34;| ApiA
ApiA --&amp;gt;|&amp;#34;same IDs, X-Requesting-Service: service-a&amp;#34;| ApiB
ApiA --&amp;gt;|&amp;#34;same IDs&amp;#34;| Lims
Dash -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiA -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
ApiB -.-&amp;gt;|&amp;#34;req-abc&amp;#34;| Logs
classDef store fill:#f3e8fd,stroke:#a142f4
class Logs store&lt;/code>&lt;/pre>&lt;p>Inside a process, the fields live in a
&lt;a href="https://docs.python.org/3/library/contextvars.html" target="_blank" rel="noopener">&lt;code>ContextVar&lt;/code>&lt;/a>, so they follow the request through &lt;code>await&lt;/code> chains and across threads without being passed as arguments. Between processes, they&amp;rsquo;re plain HTTP headers that outbound calls forward. &lt;code>X-Requesting-Service&lt;/code> is the one that changes at each hop, while the other three carry through unchanged. That is what lets one &lt;code>request_id&lt;/code> span the entire tree.&lt;/p>
&lt;hr>
&lt;h2 id="why-use-a-filter-not-a-loggeradapter">Why use a filter, not a LoggerAdapter&lt;/h2>
&lt;p>One way to attach context to log records is by using Python&amp;rsquo;s &lt;code>logging.LoggerAdapter&lt;/code>, but I think it&amp;rsquo;s the wrong tool for what I&amp;rsquo;m trying to do. An
&lt;a href="https://docs.python.org/3/library/logging.html#loggeradapter-objects" target="_blank" rel="noopener">adapter&lt;/a> applies only to log calls made &lt;em>through it&lt;/em>. Your own code gets the context, but the third-party library that raises the interesting exception does not, because it holds its own plain &lt;code>logging.getLogger(__name__)&lt;/code>.&lt;/p>
&lt;p>A &lt;code>logging.Filter&lt;/code> attached to the root handler sees every record in the process, irrespective of where it came from:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">_log_context&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">dict&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ContextVar&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;log_context&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">default&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">ContextFilter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Filter&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;Inject current context fields onto every LogRecord.&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">record&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">LogRecord&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bool&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">_global_fields&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()}&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">items&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="nb">hasattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">setattr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">record&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">value&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>hasattr&lt;/code> check means a field passed explicitly at the call site wins over the passive default context, so a caller can override &lt;code>user_id&lt;/code> for one line without unbinding it. And &lt;code>_global_fields&lt;/code> is a plain dict rather than a ContextVar, because ContextVar values aren&amp;rsquo;t inherited by new threads: static configuration like the service name has to live outside the context or it vanishes in a thread pool.&lt;/p>
&lt;p>Binding is a context manager that nests:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@contextmanager&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">current&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="n">current&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="o">**&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">yield&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">_log_context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">reset&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">session_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">sid&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;request received&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># two fields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">step&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;validate&amp;#34;&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;validating&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># three&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;continuing&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># back to two&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>token&lt;/code> mechanism restores the previous context exactly, including when the block exits by exception, which a naive save-and-restore gets wrong.&lt;/p>
&lt;p>There&amp;rsquo;s also a version without a scope boundary, for values that resolve partway through a request. Identity is the usual case: you don&amp;rsquo;t know the user until auth has run, but you want every line after that point to carry it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_current_user&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="n">User&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">update_log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">user_id&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">user&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">email&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="c1"># every later line carries it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">user&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="middleware">Middleware&lt;/h2>
&lt;p>The middleware operates once per request: accept or generate the IDs, resolve the user, bind everything, time the request, and echo the IDs back.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">request_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-request-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;req-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">session_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">request&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">headers&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;x-session-id&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;ses-&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">uuid&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">uuid4&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Accept-or-generate is what enables cross-service correlation. The first service to see a request mints the ID, and every service after that inherits the minted ID from the header. Echoing the request and session IDs back in the response headers means a client, a browser console, or a support ticket can name the exact request, which turns &amp;ldquo;it was slow this morning&amp;rdquo; into a string I can now search for in CloudWatch. Identity resolves from whichever source is available, in priority order:&lt;/p>
&lt;ol>
&lt;li>&lt;code>x-amzn-oidc-data&lt;/code>, the ALB-injected Cognito JWT, decoded for the &lt;code>email&lt;/code> claim. This is a direct browser hit.&lt;/li>
&lt;li>&lt;code>X-User-ID&lt;/code>, forwarded by an upstream service that already decoded its own JWT.&lt;/li>
&lt;li>A dev stub, when &lt;code>APP_ENV&lt;/code> is &lt;code>dev&lt;/code>, &lt;code>local&lt;/code>, or unset.&lt;/li>
&lt;/ol>
&lt;p>The second tier carries a user&amp;rsquo;s identity into services that the user never talks to directly. Service B has no OIDC header, because its caller was service A, not a browser. It can still log which person&amp;rsquo;s click ultimately caused the call.&lt;/p>
&lt;h3 id="grading-by-status">Grading by status&lt;/h3>
&lt;p>&lt;code>request_end&lt;/code> is logged at &lt;code>ERROR&lt;/code> for 5xx, &lt;code>WARNING&lt;/code> for 4xx, and &lt;code>INFO&lt;/code> otherwise:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">status&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">400&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">INFO&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;request completed&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_end&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">status&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;duration_s&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">duration_s&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="403s-that-explain-nothing">403s that explain nothing&lt;/h2>
&lt;p>A route raises &lt;code>HTTPException(403, detail=&amp;quot;missing scope: service-b/write&amp;quot;)&lt;/code>. The client gets a useful error. The log gets a bare &lt;code>403&lt;/code> and nothing else. The reason is ordering: FastAPI&amp;rsquo;s &lt;code>ExceptionMiddleware&lt;/code> sits &lt;em>inside&lt;/em> the middleware, so by the time the middleware sees anything, the exception has already been converted into a response. The detail explaining the rejection went to the client and nowhere else. At that point, debugging a 403 means reproducing it, which is annoying and what this whole codebase is meant to fix.&lt;/p>
&lt;p>The fix is an exception handler that records the reason, registered alongside the middleware:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">http_exception_log_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">log&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">ERROR&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&lt;/span> &lt;span class="o">&amp;gt;=&lt;/span> &lt;span class="mi">500&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">logging&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">WARNING&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;request rejected&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;event&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_rejected&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;status_code&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">status_code&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;error_detail&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">_detail_text&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">exc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">detail&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">_default_http_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">request&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exc&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_exception_handler&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">StarletteHTTPException&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">http_exception_log_handler&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>It runs inside the middleware&amp;rsquo;s context, so the request, session, and user fields are already bound. It delegates to Starlette&amp;rsquo;s default handler, so the response the client receives is unchanged. And because &lt;code>detail&lt;/code> is often a dict for structured errors, it gets JSON-serialized rather than &lt;code>str()&lt;/code>-ed, so it stays greppable instead of turning into Python repr with single quotes.&lt;/p>
&lt;hr>
&lt;h2 id="health-probe-overload">Health probe overload&lt;/h2>
&lt;p>A load balancer probes &lt;code>/health&lt;/code> every few seconds from every node, which means that those log lines can dominate log volume while reporting only that nothing happened, especially if they&amp;rsquo;re occuring frequently. I chose to suppress them, while still retaining useful information in the case that they fail:&lt;/p>
&lt;ol>
&lt;li>Skip the &lt;code>request_start&lt;/code>/&lt;code>request_end&lt;/code> pair in the middleware for &lt;em>passing&lt;/em> probes.&lt;/li>
&lt;li>Drop uvicorn&amp;rsquo;s own access-log line for the same requests, via a filter on the root handler.&lt;/li>
&lt;/ol>
&lt;p>Uvicorn logs independently of your middleware, so leaving that half in place means you&amp;rsquo;ve halved the noise and kept the volume. A probe returning 4xx or 5xx is always logged &amp;ndash; that&amp;rsquo;s the whole point of building this logging functionality. The correlation headers are still echoed on suppressed requests too, so if a probe starts failing and something retries, the retry is traceable.&lt;/p>
&lt;hr>
&lt;h2 id="auto-capturing-tracebacks">Auto-capturing tracebacks&lt;/h2>
&lt;p>A log call made from inside an &lt;code>except&lt;/code> block captures the active exception automatically, even when the caller writes &lt;code>log.warning(&amp;quot;call failed&amp;quot;)&lt;/code> with no &lt;code>exc_info=True&lt;/code>. I typically write &lt;code>logger.warning&lt;/code> in error handlers constantly, and almost never remember &lt;code>exc_info&lt;/code>. This results in a log full of &amp;ldquo;call failed&amp;rdquo; with no stacktrace. Capturing it by default means the traceback is there whether or not the author thought about it.&lt;/p>
&lt;p>The record then carries both locations, which are usually different:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;2026-05-26T19:09:37.015695Z&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;level&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;WARNING&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;service&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lims-adapter&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;message&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;LIMS call failed: SQL query failed (upstream_status=405)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;func&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;_lims_error&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;filename&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;errors.py&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;lineno&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="mi">47&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;request_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;req-474c2493-18ea-42a0-be0f-5ce4d096b14c&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;session_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;ses-a55813da-3ab1-4471-9761-59d2ef988179&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;user_id&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;user@example.com&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;path&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;/samples/S-20260129-269/molecule&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;exception&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;Traceback (most recent call last):\n File \&amp;#34;.../molecule.py\&amp;#34;, line 10, in get_molecule\n ...\nLimsError: SQL query failed&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>filename&lt;/code> and &lt;code>lineno&lt;/code> point at the error-handling code: the place someone decided to log. The traceback points somewhere else entirely, at the origin of the raise. That is where the bug lives. You want both, and they are rarely in the same file.&lt;/p>
&lt;hr>
&lt;h2 id="field-names-and-markers">Field names and markers&lt;/h2>
&lt;p>&lt;strong>Constants instead of string literals.&lt;/strong> A typo in &lt;code>fields.REQUEST_ID&lt;/code> is a &lt;code>NameError&lt;/code> at import. A typo in &lt;code>&amp;quot;request_id&amp;quot;&lt;/code> is a silent schema divergence that nobody notices until a dashboard query quietly returns fewer rows than it should.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">logging_lib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">fields&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">log_context&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">**&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">REQUEST_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">rid&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">USER_ID&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">uid&lt;/span>&lt;span class="p">}):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">log&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;handling request&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">extra&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="n">fields&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">EVENT&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;request_start&amp;#34;&lt;/span>&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Markers for categories.&lt;/strong> A top-level &lt;code>marker&lt;/code> field tags a record for dashboards and alerting: &lt;code>AUDIT&lt;/code> for user-initiated state changes, &lt;code>PERF&lt;/code> for timings, &lt;code>SECURITY&lt;/code> for auth and access control, &lt;code>SYSTEM&lt;/code> for lifecycle, &lt;code>PIPELINE&lt;/code> for job execution. It&amp;rsquo;s a small closed vocabulary, which is the point, and it makes queries trivial in whatever you&amp;rsquo;re storing logs in:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl"># CloudWatch Logs Insights
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">filter marker = &amp;#34;AUDIT&amp;#34;
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"># Loki
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">{service=&amp;#34;lims-adapter&amp;#34;} | json | marker = &amp;#34;AUDIT&amp;#34;&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-i-now-get">What I now get&lt;/h2>
&lt;p>With the IDs in place, three questions become queries rather than investigations.&lt;/p>
&lt;p>&lt;strong>Sequencing.&lt;/strong> Filter on one &lt;code>request_id&lt;/code>, sort by timestamp, and I have the ordered list of everything every service did for that one call, including the services the user never touched directly.&lt;/p>
&lt;p>&lt;strong>Lineage.&lt;/strong> &lt;code>requesting_service&lt;/code> on each hop gives the call graph as it actually ran&lt;/p>
&lt;p>&lt;strong>Latency attribution.&lt;/strong> Every service records &lt;code>duration_s&lt;/code> for the same &lt;code>request_id&lt;/code>. The dashboard reports 3.1 seconds, service A reports 2.9, service B reports 2.7, and the LIMS query reports 2.6. The slow thing is the LIMS query, and it&amp;rsquo;s established without adding a single timer.&lt;/p></description></item><item><title>Service-to-Service Auth with Cognito Scopes</title><link>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</link><pubDate>Tue, 09 Dec 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/service-to-service-auth-cognito/</guid><description>&lt;p>I&amp;rsquo;m building tools that help scientists run their day-to-day work. I work in a heavily regulated environment, subject to both U.S. and European digital policy requirements. The scenario we face regularly is: two services talk to each other. Then three. A dashboard calls a backend API, that API calls another one, a nightly job calls all of them. Somewhow, we have to answer how one service proves to another that it is allowed to make the call.&lt;/p>
&lt;p>The first way I addressed this was to use Internal Bearer Tokens. Basically, you generate a long random string, store it in the AWS Secrets Manager, inject it into every service as an environment variable, have each service check inbound requests for it.&lt;/p>
&lt;p>I replaced that solution with Cognito&amp;rsquo;s OAuth2 client-credentials flow. Here each service carries its own identity and each endpoint declares the scope it requires. We didn&amp;rsquo;t need to define any new infrastructure for this, since Cognito was already authenticating browser users through the ALB, so the service-to-service (S2S) path ended up reusing the same user pool, the same JWKS endpoint, and the same validation code that was already there.&lt;/p>
&lt;hr>
&lt;h2 id="why-a-shared-bearer-token-was-not-viable">Why a shared bearer token was not viable&lt;/h2>
&lt;p>The bearer token approach was really easy, but it wasn&amp;rsquo;t a viable approach in the long run, for a few reasons:&lt;/p>
&lt;p>&lt;strong>No expiry.&lt;/strong> A static token has no TTL. If it leaks through a log line, a memory dump, or a compromised container, it stays valid until you manually rotate it and run a redeploy of every service holding it. There is no safe window during rotation where both the old and new values work, unless you write that special-casing yourself.&lt;/p>
&lt;p>&lt;strong>No identity.&lt;/strong> When service B receives the token, it can verify the value matches what it was given, but it can&amp;rsquo;t tell &lt;em>who&lt;/em> sent it. Every caller is indistinguishable, so you can only observe that the token was used.&lt;/p>
&lt;p>&lt;strong>No scope.&lt;/strong> The receiving service has no way to enforce what the caller may do. Either it accepts the token and grants full access, or it rejects it. There&amp;rsquo;s no way to express &amp;ldquo;service A may read but not write&amp;rdquo;.&lt;/p>
&lt;p>&lt;strong>Shared blast radius.&lt;/strong> The same secret works everywhere. One compromised service exposes the key to every other service, so a single breach leaves a huge hole.&lt;/p>
&lt;p>Comparing these two solutions against each other, we have:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Property&lt;/th>
&lt;th>Static bearer token&lt;/th>
&lt;th>Cognito client credentials&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Expiry&lt;/td>
&lt;td>Never&lt;/td>
&lt;td>~1 hour, auto-refreshed&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Identity&lt;/td>
&lt;td>None (opaque string)&lt;/td>
&lt;td>Signed JWT with a &lt;code>client_id&lt;/code> claim&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Scope enforcement&lt;/td>
&lt;td>Impossible&lt;/td>
&lt;td>Per-endpoint, per-caller&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Compromise blast radius&lt;/td>
&lt;td>All services, indefinitely&lt;/td>
&lt;td>One service, for TTL&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Rotation&lt;/td>
&lt;td>Manual, coordinated redeploy&lt;/td>
&lt;td>Automatic&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Auditability&lt;/td>
&lt;td>&amp;ldquo;the token was used&amp;rdquo;&lt;/td>
&lt;td>&amp;ldquo;service A called endpoint X&amp;rdquo;&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Being able to attribute traffic to a caller enhances the utility of logs, which is most of the subject of
&lt;a href="https://kristianeschenburg.netlify.app/post/request-correlation-middleware/">my next post&lt;/a>.&lt;/p>
&lt;hr>
&lt;h2 id="two-gates">Two gates&lt;/h2>
&lt;p>There are two authentication and authorization gates in this architecture:&lt;/p>
&lt;p>&lt;strong>Gate 1 is the ALB.&lt;/strong> Everything in our system is either accesible from outside our VPC via the ALB, or has ingress/egress restricted to within the VPC. The ALB decides only &lt;em>how a request gets in&lt;/em>: either this is an authenticated browser user who must complete an interactive Cognito login, or it&amp;rsquo;s a caller to hand straight to the service. The ALB knows nothing about your application&amp;rsquo;s permissions.&lt;/p>
&lt;p>&lt;strong>Gate 2 is the service.&lt;/strong> It validates &lt;em>identity&lt;/em> and enforces &lt;em>scope&lt;/em>. This is the real authorization check for S2S communication. It behaves identically no matter what language or host the caller runs on.&lt;/p>
&lt;p>An incoming request has to clear both. A browser user who hasn&amp;rsquo;t been authenticated gets a 302 status to the Cognito login page via Gate 1 and never reaches the dashboard or API. A service with a valid token but the wrong scope passes Gate 1 but receives a 403 at Gate 2.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">caller ──▶ [ Gate 1: ALB ] ──▶ [ Gate 2: service ] ──▶ your handler
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> browser or machine? who are you, and what may you do?
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ browser, │ no or invalid credential
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ not logged in │ valid token, wrong scope
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼ ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> 302 → Cognito login 401 / 403&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="what-services-can-call-what">What services can call what&lt;/h2>
&lt;p>Below is a diagram of allowed communication. Solid arrows are requests, and each one is labeled with the scope it must present. Dashed arrows are token issuance.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Human(Browser user)
Robot(Laptop / CI job)
ALB{ALB}
Dash[Dashboard]
SvcA[Service A]
SvcB[Service B]
SvcC[Service C]
Cognito(Cognito)
Human --&amp;gt;|&amp;#34;session cookie, no scope&amp;#34;| ALB
Robot --&amp;gt;|&amp;#34;Authorization: Bearer&amp;#34;| ALB
ALB --&amp;gt;|&amp;#34;x-amzn-oidc-identity&amp;#34;| Dash
ALB --&amp;gt;|&amp;#34;Bearer passthrough&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-a/read&amp;#34;| SvcA
Dash --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-b/read&amp;#34;| SvcB
SvcA --&amp;gt;|&amp;#34;service-c/write&amp;#34;| SvcC
Cognito -.-&amp;gt;|&amp;#34;token: service-a/read, service-b/read&amp;#34;| Dash
Cognito -.-&amp;gt;|&amp;#34;token: service-b/read, service-c/write&amp;#34;| SvcA
classDef human fill:#e8f0fe,stroke:#4285f4
classDef machine fill:#e6f4ea,stroke:#34a853
class Human human
class Robot machine&lt;/code>&lt;/pre>&lt;p>A browser user reaches the dashboard with no scope at all, because the ALB already authenticated them interactively and the dashboard is the thing they&amp;rsquo;re allowed to look at. The dashboard&amp;rsquo;s own token is &lt;em>narrower&lt;/em> than service A&amp;rsquo;s: it can read service A and service B, but nothing grants it write access to service C. If the dashboard is compromised, the write path isn&amp;rsquo;t reachable from there.&lt;/p>
&lt;p>The permissions are just a table, so adding a consumer means adding a row, not changing any service&amp;rsquo;s code:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>App client (the caller)&lt;/th>
&lt;th>Allowed scopes&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>dashboard&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>service-a&lt;/code>&lt;/td>
&lt;td>&lt;code>service-b/read&lt;/code>, &lt;code>service-c/write&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>ci-runner&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>developer&lt;/code>&lt;/td>
&lt;td>&lt;code>service-a/read&lt;/code>, &lt;code>service-b/read&lt;/code>&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="cognito-resource-servers-custom-scopes-and-app-clients">Cognito resource servers, custom scopes, and app clients&lt;/h2>
&lt;h3 id="the-resource-server-defines-capabilities">The resource server defines capabilities&lt;/h3>
&lt;p>A &lt;strong>resource server&lt;/strong> represents a service that exposes capabilities. It has two
name-like fields that do very different jobs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># terraform code
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_cognito_resource_server&amp;#34; &amp;#34;service_b&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cognito_user_pool_id&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # The scope prefix. Opaque string, conventionally a URL. Immutable in practice.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> identifier&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;https://api.example.com/service-b&amp;#34;&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Console display name only. Nothing references it.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Read access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;write&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Write access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">scope&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Administrative access to service-b&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>identifier&lt;/code> field becomes the prefix of every scope the
resource server defines, so the three scopes above are really:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/read
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/write
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">https://api.example.com/service-b/admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>That full string is what a client requests, what appears in the JWT&amp;rsquo;s &lt;code>scope&lt;/code>
claim, and what a service compares against. &lt;code>name&lt;/code> is a display label in the
console and is referenced by nothing.&lt;/p>
&lt;h3 id="only-custom-scopes-work-for-machine-to-machine">Only custom scopes work for machine-to-machine&lt;/h3>
&lt;p>The client-credentials flow works &lt;strong>only&lt;/strong> with custom scopes from a resource server. I defined these scope manually in my FastAPI applications, and decorated the relevant endpoints with their respective scopes. The built-in OpenID scopes (&lt;code>openid&lt;/code>, &lt;code>email&lt;/code>, &lt;code>profile&lt;/code>, &lt;code>aws.cognito.signin.user.admin&lt;/code>)
are for user-facing flows, and asking for one with &lt;code>grant_type=client_credentials&lt;/code>
fails. Every machine caller needs at least one resource server to exist,
even if the service it calls has exactly one capability.&lt;/p>
&lt;p>The app client also needs a secret and the flow explicitly enabled. A client
without &lt;code>generate_secret&lt;/code> cannot do client credentials at all, and the user pool
needs a domain configured, since the token endpoint lives at
&lt;code>https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&lt;/code>.&lt;/p>
&lt;h3 id="the-app-client-selects-a-subset">The app client selects a subset&lt;/h3>
&lt;p>An &lt;strong>app client&lt;/strong> represents a caller of a service. One per calling service, with
&lt;code>allowed_oauth_scopes&lt;/code> listing every downstream capability it may request:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_cognito_user_pool_client&amp;#34; &amp;#34;service_a_m2m&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;service-a-m2m&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cognito_user_pool_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> generate_secret&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows_user_pool_client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kt">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> allowed_oauth_flows&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;client_credentials&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Full scope strings, exactly as composed above.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> allowed_oauth_scopes&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;https://api.example.com/service-b/read&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;https://api.example.com/service-c/write&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Cognito validates that each scope exists, so the resource servers
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # must be created first. Terraform will not infer this ordering.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> depends_on&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_b&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_cognito_resource_server&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">service_c&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Service A is granted &lt;code>read&lt;/code> on service B, and &lt;code>write&lt;/code> on service C. It cannot write to service B even though that scope exists, because its app client never lists it as a viable permissions. Cognito rejects an app client referencing a scope that doesn&amp;rsquo;t exist yet, and Terraform sees no dependency between the two resources because the scope is a hand-written string rather than a reference to the resource server&amp;rsquo;s attributes.&lt;/p>
&lt;p>&lt;strong>Scopes model capabilities, not consumers.&lt;/strong> Service B declares that reading,
writing, and administering are things that can be done to it (this is defined both in the FastAPI code via decorated endpoints, and in the Cognito resource server). But Service B doesn&amp;rsquo;t say anything about who can take those actions. Consumers get assigned action permissions through their app client&amp;rsquo;s allowed-scopes list.&lt;/p>
&lt;p>That separation lets you onboard a caller without touching the
service being called. A new consumer is one app client and one secret. Service B
does not redeploy, does not learn the new caller&amp;rsquo;s name, and does not grow a
config entry. Its code already says &lt;code>require_scope(&amp;quot;read&amp;quot;)&lt;/code> and will keep saying
that no matter how many services eventually call it.&lt;/p>
&lt;h3 id="two-possible-gotchas">Two possible gotchas&lt;/h3>
&lt;p>&lt;strong>An omitted scope parameter is not the same as no scopes.&lt;/strong> If a token request
leaves &lt;code>scope&lt;/code> off entirely, Cognito issues a token carrying &lt;em>every&lt;/em> scope the app
client is allowed. This means that every downstream call then presents maximum privilege. Always request scopes explicitly, one per downstream call. This is also why caching tokens per scope rather than per client matters.&lt;/p>
&lt;p>&lt;strong>Removing a scope is harder than adding one.&lt;/strong> Cognito will not let you delete a
scope from a resource server while an app client still lists it in
&lt;code>allowed_oauth_scopes&lt;/code>. The order is: remove it from every app client, apply, then
remove it from the resource server. In Terraform that&amp;rsquo;s two applies, and doing it
in one produces an error that names the resource server rather than the app client that&amp;rsquo;s actually the problem.&lt;/p>
&lt;h2 id="validating-a-token">Validating a token&lt;/h2>
&lt;p>The receiving side has to check four things:&lt;/p>
&lt;ol>
&lt;li>The &lt;strong>signature&lt;/strong>, against Cognito&amp;rsquo;s public JWKS for the pool.&lt;/li>
&lt;li>The &lt;strong>issuer&lt;/strong>, so a token from some other pool isn&amp;rsquo;t accepted.&lt;/li>
&lt;li>&lt;code>token_use == &amp;quot;access&amp;quot;&lt;/code>, so an ID token can&amp;rsquo;t be substituted for an access token.&lt;/li>
&lt;li>The &lt;strong>scope claim&lt;/strong> contains the specific scope the endpoint requires.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_decode_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">token&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">signing_key&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_jwk_client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_signing_key_from_jwt&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">token&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">claims&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">jwt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">decode&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">token&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">signing_key&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">key&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">algorithms&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;RS256&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">issuer&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">issuer&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Client-credentials tokens carry `client_id` instead of `aud`,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># so skip audience verification and check token_use instead.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">options&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;verify_aud&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="kc">False&lt;/span>&lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;token_use&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;access&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">401&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;wrong token type&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">claims&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Client-credentials tokens have no &lt;code>aud&lt;/code> claim. Instead, they carry &lt;code>client_id&lt;/code> , so leaving audience verification on rejects every valid S2S token with a confusing error. Turning it off here is valid, but only because &lt;code>token_use&lt;/code> and the issuer are being checked instead.&lt;/p>
&lt;p>Scope enforcement then becomes a dependency on the router:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">full_scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resource_server_id&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">/&lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_scope&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">full_scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">claims&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;scope&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">split&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="n">HTTPException&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">403&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;token missing required scope: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">full_scope&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">app&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">FastAPI&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="p">)])&lt;/span> &lt;span class="c1"># authenticated everywhere&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">router&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">APIRouter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dependencies&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Depends&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">auth&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">require_scope&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;read&amp;#34;&lt;/span>&lt;span class="p">))])&lt;/span> &lt;span class="c1"># plus a scope&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>There is also a design decision buried here. Browser requests arriving with ALB OIDC headers pass the scope check without a scope, since the ALB already authenticated the human interactively and browser sessions have no scopes to check.&lt;/p>
&lt;hr>
&lt;h2 id="fetching-a-token">Fetching a token&lt;/h2>
&lt;p>The outbound half fetches tokens from Cognito and caches them until they expire.&lt;/p>
&lt;p>&lt;strong>Cache per scope, not per client.&lt;/strong> A service calling three downstream APIs holds three tokens, each carrying only the access it needs for that call.&lt;/p>
&lt;p>&lt;strong>Guard the refresh with a lock, and re-check inside it.&lt;/strong> Under concurrent load, several coroutines will notice the expired token at the same moment and all stampede Cognito&amp;rsquo;s token endpoint:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">get_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">scope&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">scope&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">asyncio&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Lock&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">async&lt;/span> &lt;span class="k">with&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_locks&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Re-check inside the lock: another coroutine may have fetched already.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">cached&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">cached&lt;/span> &lt;span class="ow">and&lt;/span> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">time&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">&amp;lt;&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">expires_at&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">cached&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_fetch_new_token&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">_tokens&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">scope&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">access_token&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Expiry is stored with a 60 second skew subtracted, so a token is treated as expired slightly before Cognito thinks so. Without that, a token that passes the check and then spends 400ms in flight can arrive already invalid.&lt;/p>
&lt;hr>
&lt;h2 id="required-alb-rules">Required ALB rules&lt;/h2>
&lt;p>If your ALB has a Cognito authentication action on its listener rule, it applies to &lt;em>every&lt;/em> request on that rule. A service presenting a valid Bearer token gets a 302 redirect to the Cognito login page. It never reaches your service, so all that careful token validation never runs.&lt;/p>
&lt;p>The listener needs to rules, defined in order:&lt;/p>
&lt;ol>
&lt;li>&lt;strong>Higher priority:&lt;/strong> match requests carrying an &lt;code>Authorization: Bearer&lt;/code> header and &lt;code>forward&lt;/code> them straight to the EC2 target group, with no authenticate action.&lt;/li>
&lt;li>&lt;strong>Lower priority:&lt;/strong> everything else gets &lt;code>authenticate-cognito&lt;/code> and the normal browser login.&lt;/li>
&lt;/ol>
&lt;h3 id="gotcha-source-ip-bypass-breaks-browser-login">Gotcha! Source-IP bypass breaks browser login&lt;/h3>
&lt;p>With respect to Gate 1, you could also consider matching on &lt;strong>source IP&lt;/strong> instead, forwarding anything from an allowlisted CIDR. This fixes Gate 1, and Gate 2 is unchanged either way.&lt;/p>
&lt;p>However, a source-IP matching rule forwards &lt;em>without&lt;/em> running the authenticate action. So once you allowlist the office or VPN range, a person opening a dashboard in a browser from that network skips the login entirely and arrives with no session and no token. This is DEFINITELY NOT what you want, because we want all requests to be authenticated. This is specific to browser users, and S2S communication still works.&lt;/p>
&lt;p>Also, allowlisting the &lt;strong>VPC&amp;rsquo;s own CIDR&lt;/strong> admits in-VPC services only. A public ALB sees a laptop&amp;rsquo;s corporate or VPN &lt;em>egress&lt;/em> IP. That address is nowhere near the VPC range. So a VPC-CIDR rule never admits laptops, even on VPN. If in-VPC calls work and laptops get redirected to a login page, this is why.&lt;/p>
&lt;h3 id="gotcha-header-bypass-can-worsen-an-authentication-bug">Gotcha! Header bypass can worsen an authentication bug&lt;/h3>
&lt;p>We trust browser requests based on the presence of the &lt;code>x-amzn-oidc-identity&lt;/code> header, and that the browser path skips scope checks. That&amp;rsquo;s normally safe, because the ALB&amp;rsquo;s Cognito action &lt;em>overwrites&lt;/em> that header on every request it processes, so a client can&amp;rsquo;t forge it.&lt;/p>
&lt;p>A bypassed request doesn&amp;rsquo;t run that action, so with a header-match rule, anyone on the internet can send:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">Authorization: Bearer anything
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">x-amzn-oidc-identity: someone@example.com&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first header triggers the bypass. The second makes your service treat the request as an authenticated browser user, which skips the scope check entirely. The &lt;code>Bearer&lt;/code> value is never validated, because the ALB-header path returns before the token path is reached.&lt;/p>
&lt;p>The mitigations, either of which closes it:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>Strip inbound &lt;code>x-amzn-oidc-*&lt;/code> headers on the bypass path&lt;/strong>, so only the ALB can ever set them.&lt;/li>
&lt;li>&lt;strong>Dont trusting identity by presence of the header alone.&lt;/strong> Verify the signed &lt;code>x-amzn-oidc-data&lt;/code> JWT rather than reading &lt;code>x-amzn-oidc-identity&lt;/code> as a plain string.&lt;/li>
&lt;/ul>
&lt;p>The second is the better fix because it removes a whole class of bugs instance of one instance of it.&lt;/p>
&lt;hr>
&lt;h2 id="calling-it-from-anywhere">Calling it from anywhere&lt;/h2>
&lt;p>Something that confused me initially was that &lt;code>curl&lt;/code> requests from the CLI would fail. But this was because we weren&amp;rsquo;t passing in any form of authentication into the request, which made local testing or scenarios like calling a service directly from an EC2 instance, fail. I enabled this functionality with two HTTP steps that are pretty much identical in Python, Java, or a shell script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">TOKEN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="k">$(&lt;/span>curl -s -X POST &lt;span class="s2">&amp;#34;https://&amp;lt;domain&amp;gt;.auth.&amp;lt;region&amp;gt;.amazoncognito.com/oauth2/token&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -u &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$CLIENT_ID&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="nv">$CLIENT_SECRET&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="nv">grant_type&lt;/span>&lt;span class="o">=&lt;/span>client_credentials &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -d &lt;span class="s1">&amp;#39;scope=https://api.example.com/service-a/read&amp;#39;&lt;/span> &lt;span class="p">|&lt;/span> jq -r .access_token&lt;span class="k">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">curl &lt;span class="s2">&amp;#34;https://api.example.com/service-a/things&amp;#34;&lt;/span> -H &lt;span class="s2">&amp;#34;Authorization: Bearer &lt;/span>&lt;span class="nv">$TOKEN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>A Python client is a convenience wrapper around these steps &amp;ndash; it&amp;rsquo;s not the ONLY way to run this authorization flow. Anything that can make an HTTPS request can participate. But we might want to alleviate this by building in a seperate app client for developers and for automation purposes.&lt;/p>
&lt;hr>
&lt;h2 id="wrapping-up">Wrapping up&lt;/h2>
&lt;p>This was a pretty approachable problem, especially since we already had the infrastructure in place. We were already using Cognito to authenticate browser users through the ALB, and the client-credentials flow runs against the same user pool, the same JWKS endpoint, and the same validation code. We just needed one more OAuth flow on the system already in place.&lt;/p></description></item><item><title>Deploying Dagster to AWS ECS, Part 2: Pipelines</title><link>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/</link><pubDate>Wed, 05 Nov 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/</guid><description>&lt;p>Here is the second of two posts on running Dagster in ECS. The
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">first&lt;/a> dealt with the platform side: the Daemon, the two Webservers, the ALB and Cognito wiring, the three security groups, the Cloud Map namespace, and the IAM that lets the Daemon launch anything at all.&lt;/p>
&lt;p>The first component I referred to as &lt;code>dagster-platform&lt;/code>. This one is about what I&amp;rsquo;m referring to as &lt;code>dagster-pipeline&lt;/code>, the module you instantiate once per pipeline. Platform infrastructure gets deployed infrequently. Pipelines get deployed several times a day by whoever happens to be working on them. The reason I separated the two is that pushing a new job should never touch a security group, an IAM policy, or a load balancer rule.&lt;/p>
&lt;hr>
&lt;h2 id="the-contract">The Contract&lt;/h2>
&lt;p>
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">Part One&lt;/a> ended with the four things the platform expects from a pipeline:&lt;/p>
&lt;ol>
&lt;li>Register a service in the Cloud Map namespace, so the Daemon and Webserver can resolve it by DNS on port 4000.&lt;/li>
&lt;li>Attach the usercode service to the platform&amp;rsquo;s usercode security group, so that traffic is actually allowed.&lt;/li>
&lt;li>Tag its run roles with &lt;code>dagster:component&lt;/code> and &lt;code>dagster:managed-by&lt;/code>, so the Daemon is permitted to pass them to ECS.&lt;/li>
&lt;li>Get its code location into &lt;code>workspace.yaml&lt;/code> in SSM, so the Webserver and Daemon know it exists.&lt;/li>
&lt;/ol>
&lt;p>The platform defines the security group and the pipeline looks it up by name:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/ecs.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.usercode_sg}-${var.platform_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here is the whole module and everything it touches. Blue is the platform, which this module does not create and only attaches to. Green is what the pipeline module builds.&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Daemon[daemon]
Web[webserver]
UcSvc[usercode service]
RunTD(run task def)
Run[run task]
CM(Cloud Map)
ECR(ECR)
SM(Secrets Manager)
PG(Postgres)
S3(S3)
SrcDB(source DB)
Web --&amp;gt;|4000| UcSvc
Daemon --&amp;gt;|4000| UcSvc
UcSvc -.-&amp;gt;|register| CM
CM -.-&amp;gt;|resolve| Daemon
UcSvc -.-&amp;gt;|names| RunTD
Daemon --&amp;gt;|RunTask| RunTD
RunTD -.-&amp;gt;|instantiates| Run
ECR -.-&amp;gt;|image| UcSvc
ECR -.-&amp;gt;|image| Run
SM --&amp;gt;|exec role| UcSvc
SM --&amp;gt;|task role| Run
UcSvc --&amp;gt;|5432| PG
Run --&amp;gt;|5432| PG
Run --&amp;gt;|443| S3
Run --&amp;gt;|query| SrcDB
classDef platform fill:#e8f0fe,stroke:#4285f4
classDef pipeline fill:#e6f4ea,stroke:#34a853
classDef ephemeral fill:#fef7e0,stroke:#f9ab00
classDef backend fill:#f1f3f4,stroke:#9aa0a6
class Daemon,Web platform
class UcSvc,CM,RunTD pipeline
class Run ephemeral
class ECR,SM,PG,S3,SrcDB backend&lt;/code>&lt;/pre>&lt;p>The run task definition is a &lt;em>definition&lt;/em>, not a running thing: the usercode container names it in an environment variable, and the daemon is what actually instantiates it. The arrows pointing into the Secrets Manager land on different roles, because the usercode service has its credentials injected by ECS before it starts while the run task reads them itself at runtime.&lt;/p>
&lt;hr>
&lt;h2 id="the-two-task-definition-pattern">The Two-Task-Definition Pattern&lt;/h2>
&lt;p>Each pipeline module creates &lt;strong>two&lt;/strong> task definitions:&lt;/p>
&lt;p>&lt;strong>&lt;code>usercode&lt;/code> task definition&lt;/strong>: the always-on code server. This runs &lt;code>dagster code-server start&lt;/code> and stays alive, serving job definitions, sensors, schedules, and so on to the Daemon. It registers with Cloud Map so the platform can discover it.&lt;/p>
&lt;p>&lt;strong>&lt;code>run&lt;/code> task definition&lt;/strong>: the task that executes a job. When a run is triggered, the EcsRunLauncher spins up a new Fargate task using this definition. It&amp;rsquo;s ephemeral, meaning it starts, runs the job, and exits.&lt;/p>
&lt;p>The two definitions point at the same image and differ mainly in CPU/memory and in which IAM roles they use. The &lt;code>run&lt;/code> definition&amp;rsquo;s &lt;code>command&lt;/code> doesn&amp;rsquo;t really matter, since the launcher overrides it with the actual run command, but I set it to the same code-server command so that starting the task by hand does something reasonable. The usercode container needs to know about the run task definition so it can tell Dagster which task to launch, which can be achieved with two environment variables.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_current_image_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CURRENT_IMAGE&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_ecr_image&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">usercode&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">image_uri&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_context&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ecs&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> task_definition_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ecs_task_definition&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> container_name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_context_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CONTAINER_CONTEXT&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">jsonencode&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_container_context&lt;/span>&lt;span class="p">)&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>DAGSTER_CURRENT_IMAGE&lt;/code> is the pipeline&amp;rsquo;s ECR image URI. &lt;code>DAGSTER_CONTAINER_CONTEXT&lt;/code> tells Dagster which ECS task definition and container name to use for runs. Note that &lt;code>container_name&lt;/code> has to match the &lt;code>name&lt;/code> field of the container in the run task definition exactly. If it doesn&amp;rsquo;t, the run task launches and Dagster can&amp;rsquo;t find the container it&amp;rsquo;s supposed to be watching. Both of these go on the &lt;strong>usercode&lt;/strong> container only, not the run container. Building &lt;code>DAGSTER_CONTAINER_CONTEXT&lt;/code> from &lt;code>aws_ecs_task_definition.run.arn&lt;/code> also creates the dependency that forces Terraform to build the run task definition first.&lt;/p>
&lt;hr>
&lt;h2 id="registering-with-cloud-map">Registering With Cloud Map&lt;/h2>
&lt;p>The platform created the private DNS namespace, and each pipeline creates its own service record inside of that DNS namespace:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/cloud_map.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Look up the namespace created by the platform module
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_dns_namespace&amp;#34; &amp;#34;dagster&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DNS_PRIVATE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipelines-${var.env}.usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Each pipeline gets its own service record in the shared namespace
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_service&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.pipeline_name}-pipeline-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">dns_config&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> namespace_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_service_discovery_dns_namespace&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">dns_records&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ttl&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">300&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;A&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> routing_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;MULTIVALUE&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ECS service ties itself to that record with a &lt;code>service_registries&lt;/code> block, and from then on ECS registers the Fargate task&amp;rsquo;s private IP with Cloud Map automatically:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ecs_service&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # ...
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">service_registries&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> registry_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_service_discovery_service&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">usercode&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The DNS name becomes &lt;code>&amp;lt;pipeline-name&amp;gt;-pipeline-&amp;lt;env&amp;gt;.pipelines-&amp;lt;env&amp;gt;.usercode&lt;/code>, which goes into &lt;code>workspace.yaml&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># workspace.yaml (stored in SSM)&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">load_from&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">grpc_server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ingest-pipeline-prod.pipelines-prod.usercode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">location_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;ingest&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">grpc_server&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">host&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">transforms-pipeline-prod.pipelines-prod.usercode&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">4000&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">location_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;transforms&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&amp;ldquo;ingest&amp;rdquo; and &amp;ldquo;transforms&amp;rdquo; are what you see in the Dagster webserver when looking at each of your pipeline deployments.&lt;/p>
&lt;h3 id="ttl-and-task-replacement">TTL and Task Replacement&lt;/h3>
&lt;p>The &lt;code>ttl = 300&lt;/code> in that &lt;code>dns_records&lt;/code> is important here. Fargate tasks get a new private IP every time they&amp;rsquo;re replaced, and a code server is replaced on every deploy. Cloud Map updates the A record immediately, but the Daemon and Webserver are resolving that name through the VPC resolver, which respects the TTL. For a few minutes after a deploy, they can be holding the IP of a task that no longer exists, and in practice, that means that a code location goes red (a.k.a looks like a failed deployment in the UI) in the UI right after a deploy and then fixes itself a few minutes later. Decreasing the TTL to 15 or 30 seconds makes deploys settle much faster, at the cost of more DNS queries, which for a handful of code servers is probably acceptable.&lt;/p>
&lt;p>&lt;code>routing_policy = &amp;quot;MULTIVALUE&amp;quot;&lt;/code> returns every healthy instance registered under the name. With &lt;code>desired_count = 1&lt;/code> there&amp;rsquo;s only ever one, but it&amp;rsquo;s the right policy if you later run more than one replica of a code server. Similarly, when you add a new pipeline, you need to update &lt;code>workspace.yaml&lt;/code> in SSM and restart the Daemon and Webserver services so they pick up the new entry. I&amp;rsquo;m currently running this manually, but it&amp;rsquo;s worth automating in the future (but not a deal-breaker, just not something I&amp;rsquo;d consider &amp;ldquo;complete&amp;rdquo;).&lt;/p>
&lt;hr>
&lt;h2 id="four-roles-per-pipeline">Four Roles Per Pipeline&lt;/h2>
&lt;p>Each pipeline module creates four IAM roles: execution and task roles for both the &lt;strong>usercode&lt;/strong> service (always-on code server) and the &lt;strong>run&lt;/strong> task (ephemeral, launched per job execution).&lt;/p>
&lt;p>Just like I mentioned in
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/">Part One&lt;/a>, if you&amp;rsquo;re handling AWS secrets, if the value is injected by the ECS &lt;code>secrets&lt;/code> block in the task definition, the &lt;strong>execution&lt;/strong> role needs to read it. If your application code calls &lt;code>boto3&lt;/code> to fetch it, the &lt;strong>task&lt;/strong> role needs to read it.&lt;/p>
&lt;p>The roles are structurally identical, but the tags differ. The tags are what the platform&amp;rsquo;s &lt;code>iam:PassRole&lt;/code> condition checks against:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-pipeline/iam.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> usercode_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> run_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run execution role, used by ECS to start the ephemeral run container
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;run_exec&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-exec-role-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;run_exec_attach&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_exec&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Run task role, used by the running job container to call AWS APIs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;run_task&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-task-role-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="the-empty-resource-list-trap">The Empty Resource List Trap&lt;/h3>
&lt;p>IAM rejects a policy statement with an empty &lt;code>Resource&lt;/code> list, so a pipeline that connects to no databases can&amp;rsquo;t just get a policy document with zero ARNs in it. The document has to not exist at all:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;db_secrets_read&amp;#34;&lt;/span> {&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # A pipeline that reads no databases must not get this policy at all.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">length&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secret_ids&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="err">&amp;gt;&lt;/span> &lt;span class="m">0&lt;/span> &lt;span class="err">?&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ReadDatabaseSecrets&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;secretsmanager:GetSecretValue&amp;#34;, &amp;#34;secretsmanager:DescribeSecret&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">for&lt;/span> &lt;span class="k">s&lt;/span> &lt;span class="k">in&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_secretsmanager_secret&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secrets&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="k">s&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;run_db_secrets&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">length&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secret_ids&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="err">&amp;gt;&lt;/span> &lt;span class="m">0&lt;/span> &lt;span class="err">?&lt;/span> &lt;span class="m">1&lt;/span> &lt;span class="err">:&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-${var.pipeline_name}-run-db-secrets-${var.pipeline_env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">db_secrets_read&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="m">0&lt;/span>&lt;span class="p">].&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="secrets-management">Secrets Management&lt;/h2>
&lt;p>I&amp;rsquo;m using two different categories of secrets. &lt;strong>Dagster&amp;rsquo;s own Postgres credentials&lt;/strong> are stored as a single Secrets Manager secret with JSON keys. These get injected as individual environment variables using the JSON key syntax in the ECS task definition &lt;code>secrets&lt;/code> block:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_container_secrets&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_HOSTNAME&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:hostname::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_USER&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:username::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_PASSWORD&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:password::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DAGSTER_POSTGRES_DB&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${data.aws_secretsmanager_secret.dagster_postgres.arn}:name::&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>:key::&lt;/code> suffix trailing fields are the version stage and version ID. When those are empty, you get the most recent / current version. Since these are injected by ECS, the &lt;strong>execution&lt;/strong> role is what needs &lt;code>GetSecretValue&lt;/code> on the secret.&lt;/p>
&lt;p>&lt;strong>Source database credentials&lt;/strong> (for pipelines that read from other databases) are not injected at all anymore. My first version of the Terraform module wrote a &lt;code>.pgpass&lt;/code> file at container startup: each secret ID came in as &lt;code>PGPASS_SECRET_JSON_1&lt;/code>, &lt;code>PGPASS_SECRET_JSON_2&lt;/code> and so on, plus a &lt;code>PGPASS_SECRET_JSON_VARS&lt;/code> variable listing which env vars to look at, and the entrypoint fetched each one and assembled the file. It worked, but it was messy and became really annoying to manage since I was dragging around another script for building the .pgpass file. It also meant credentials sat in the container environment and on disk for the life of the task, the entrypoint had to grow a chunk of logic that had nothing to do with running jobs, so I replaced it with the following&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">variable&lt;/span> &lt;span class="s2">&amp;#34;db_secret_ids&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">list&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">string&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Secrets Manager secrets for the databases this pipeline connects to. The task role is granted GetSecretValue on them; the application reads them at runtime.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> default&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">boto3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">get_connection_details&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">secret_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">client&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">boto3&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">client&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;secretsmanager&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">secret&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">client&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_secret_value&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">SecretId&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">secret_id&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="n">json&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">loads&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">secret&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;SecretString&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The credentials are never in the environment and never on disk in the task, and adding a database is one more entry in a list variable.&lt;/p>
&lt;hr>
&lt;h2 id="what-id-do-and-still-might-do-differently">What I&amp;rsquo;d Do (and still might do&amp;hellip;) Differently&lt;/h2>
&lt;p>&lt;strong>Automate workspace.yaml updates.&lt;/strong> Right now, adding a pipeline requires a manual SSM update and service restart. A Lambda triggered by ECS task state changes, or even a simple CI step, could handle this.&lt;/p>
&lt;p>&lt;strong>Use Cognito + proper OIDC federation for access control.&lt;/strong> The dual-webserver approach works, but it&amp;rsquo;s not pretty. If you control your IdP, set up Cognito federation from the start. I tried setting up a proxy server to redirect traffic based on users belonging to one Cognito user-group or another, but this ended up being a huge headache.&lt;/p>
&lt;p>&lt;strong>Consider ECS Exec for debugging.&lt;/strong> I didn&amp;rsquo;t enable it initially and spent a lot of time reading logs trying to diagnose container startup issues. &lt;code>aws ecs execute-command&lt;/code> is worth the extra IAM policy on the task role.&lt;/p>
&lt;p>&lt;strong>Don&amp;rsquo;t put dagster.yaml in the platform images.&lt;/strong> Storing it in SSM and injecting at runtime was the right call for the daemon and webserver. Rebuilding (EVERY) image every time you want to change a config is not the move. For pipeline images, where the code changes anyway, baking it in is fine and simpler.&lt;/p>
&lt;p>&lt;strong>Fetch application secrets at runtime instead of injecting them.&lt;/strong> See above. This is the change I&amp;rsquo;m happiest about.&lt;/p>
&lt;hr>
&lt;h2 id="actually-one-more-thing">Actually, One More Thing&lt;/h2>
&lt;p>As I was writing up this post, I went back to the IAM code to check that I&amp;rsquo;d described the tag conditions correctly, and found a bug.&lt;/p>
&lt;p>Here&amp;rsquo;s the setup again. The platform&amp;rsquo;s &lt;code>iam:PassRole&lt;/code> policy only allows the Daemon to pass roles tagged &lt;code>dagster:component = &amp;quot;pipeline&amp;quot;&lt;/code>. The pipeline module&amp;rsquo;s local says something else:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">run_role_tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;run&amp;#34; # not &amp;#34;pipeline&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>&amp;quot;run&amp;quot;&lt;/code>, not &lt;code>&amp;quot;pipeline&amp;quot;&lt;/code>. By that reading the Daemon should never be able to pass these roles, and every run should fail. But runs work fine in both environments, so I had never noticed. The &lt;em>correct&lt;/em> approach is in the IaC merge command&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>merge&lt;/code> lets later arguments win, so &lt;code>var.tags&lt;/code> overrides the module&amp;rsquo;s own local. And every caller passes a tag block that happens to include exactly the key in question:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;env&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_env&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;owner&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;data-platform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;application&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;dagster&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:pipeline&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">pipeline_name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> &amp;#34;dagster:component&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipeline&amp;#34;&lt;/span>&lt;span class="c1"> # this is what makes PassRole work
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="n"> &amp;#34;dagster:managed-by&amp;#34;&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So the roles do come out tagged with &lt;code>pipeline&lt;/code>, and the security control does work, but only because of the argument order in a &lt;code>merge&lt;/code> and a tag the caller happens to set. A new pipeline that passes a &lt;code>tags&lt;/code> map without &lt;code>dagster:component&lt;/code> would plan clean, apply clean, start its code server, show up in the UI, and then fail the first time someone launched a run, with an AccessDenied on &lt;code>PassRole&lt;/code> and nothing in the Terraform to suggest why.&lt;/p>
&lt;p>The fix is to stop letting callers override the tag the policy depends on:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="n">tags&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">merge&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">tags&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">run_role_tags&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="and-change-the-platform-condition-to-match-run-which-is-the-value-the-module-actually-controls">and change the platform condition to match &lt;code>&amp;quot;run&amp;quot;&lt;/code>, which is the value the module actually controls.&lt;/h2>
&lt;h2 id="wrapping-up">Wrapping Up&lt;/h2>
&lt;p>Getting Dagster onto ECS was genuinely hard for me. The documentation covers the &amp;ldquo;in a perfect world&amp;rdquo; scenario, but not the real-world complexity of integrating with existing cloud infrastructure that you don&amp;rsquo;t partially (or entirely) control. There are plenty of &amp;ldquo;in a perfect world with a clean slate, here&amp;rsquo;s how you do it&amp;rdquo; walkthroughs, but very little in the way of justification or documentation of architecture choices. The platform/pipeline split in Terraform has been a huge win for me. I&amp;rsquo;ve templated our Dagster pipeline development platform and put together templated Dockerfiles and &lt;code>docker-compose.yaml&lt;/code> files. Deploying a new pipeline is now a &lt;code>terraform apply&lt;/code> with a handful of variables, no platform infrastructure touched, no service restarts required (except for the workspace.yaml update, which I&amp;rsquo;m working on).&lt;/p></description></item><item><title>Deploying Dagster to AWS ECS, Part 1: The Platform</title><link>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/</link><pubDate>Tue, 04 Nov 2025 09:00:00 -0800</pubDate><guid>https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-1-platform/</guid><description>&lt;p>Here at Just-Evotec Biologics, the Data Platform team uses Dagster as our orchestration platform of choice. Many of our services are deployed to AWS ECS as Fargate or EC2 tasks. Getting Dagster onto ECS took me an embarrassing number of hours, and most of those issues stemmed from a lack of documentation around getting Dagster pipelines up and running in the cloud while NOT using Dagster+ (their premium service offering). The official docs cover the happy, perfect world path, but beyond that, you&amp;rsquo;re left stitching together forum threads, GitHub issues, and a lot of trial and error. I figured I&amp;rsquo;d put together some notes on what I built, and how I built it.&lt;/p>
&lt;p>The components are of this system are two-fold &amp;ndash; the actual Python-based pipeline code, and the Terraform code I used to deploy that code to ECS. The Terraform part also comes in two pieces, because I&amp;rsquo;ve split the deployment down the middle. In this post, I&amp;rsquo;ll detail the platform half: the long-lived infrastructure everything else plugs into, such as the webserver and the daemon.
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part two&lt;/a> covers the pipeline module, what you&amp;rsquo;d instantiate per pipeline and then redeploy repeatedly as you make updates to the pipeline code.&lt;/p>
&lt;p>If you came here because something in your deployment cannot reach something else, it might be helpful for you to skip straight to the networking section, covering security groups, ports, service discovery. But if not, read on&amp;hellip;&lt;/p>
&lt;hr>
&lt;h2 id="setup">Setup&lt;/h2>
&lt;p>Our team already had a multi-environment AWS setup, with VPCs, subnets, security groups, tunneling, and load balancers in place. The goal was to plug Dagster into that existing infrastructure as cleanly as possible, with proper separation of concerns. Again, as I mentioned in some other posts, I obviously can&amp;rsquo;t share our company code, so I&amp;rsquo;ll just describe some of the approaches I took.&lt;/p>
&lt;p>I split the Terraform code into two distinct modules:&lt;/p>
&lt;ul>
&lt;li>&lt;strong>&lt;code>dagster-platform&lt;/code>&lt;/strong>: the long-lived infrastructure. Daemon, Webserver(s), shared IAM roles, ALB listener rules, SSM parameters, Cloud Map namespace, and S3/RDS backends.&lt;/li>
&lt;li>&lt;strong>&lt;code>dagster-pipeline&lt;/code>&lt;/strong>: one instantiation per pipeline. Each pipeline gets its own ECS task definitions, service, Cloud Map entry, IAM roles, and log groups. You call this module once per pipeline and it slots right into the platform.&lt;/li>
&lt;/ul>
&lt;p>This split makes sense from a frequency-of-interaction standpoint. The platform piece deploys infrequently, and doesn&amp;rsquo;t need to be modified much. Pipelines deploy constantly, sometimes multiple times a day, and I don&amp;rsquo;t want to touch platform infrastructure every time I push a new job.&lt;/p>
&lt;hr>
&lt;h2 id="architecture-overview">Architecture overview&lt;/h2>
&lt;p>Here&amp;rsquo;s the high-level picture:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">ALB (existing)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── /pipelines/admin/* → Webserver (read-write) [Cognito-authenticated]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── /pipelines/* → Webserver (read-only) [Cognito-authenticated]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Supporting Services
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── RDS PostgreSQL (run storage, event log, schedule storage)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── S3 (compute logs)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── AWS Secrets Manager (DB credentials, application secrets)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── SSM Parameter Store (dagster.yaml, workspace.yaml)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── Cloud Map (service discovery for usercode servers)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">ECS Cluster (existing)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-daemon (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-webserver-rw (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── dagster-webserver-ro (always-on Fargate service)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── pipeline-&amp;lt;name&amp;gt; (always-on usercode service, one per pipeline)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Everything above the &lt;code>pipeline-&amp;lt;name&amp;gt;&lt;/code> line is the platform &amp;ndash; that&amp;rsquo;s what this current post is about. The Daemon and Webserver containers share the same &lt;code>dagster.yaml&lt;/code> and &lt;code>workspace.yaml&lt;/code>, loaded at runtime from SSM. Pipelines register themselves via Cloud Map so the Daemon can find them.&lt;/p>
&lt;hr>
&lt;h2 id="the-read-only-webserver-problem">The read-only webserver problem&lt;/h2>
&lt;p>Dagster&amp;rsquo;s &lt;code>dagster-webserver&lt;/code> supports a &lt;code>--read-only&lt;/code> flag, but there&amp;rsquo;s no built-in authentication layer that lets you say &amp;ldquo;scientists get read-only access, data engineers get admin access.&amp;rdquo; Dagster does have a subscription tier called Dagster+ that &lt;em>does&lt;/em> offer this functionality, but we do not pay for that. So, we&amp;rsquo;re lefting putting our own solution in place. I&amp;rsquo;m not sure if there is a &amp;ldquo;right&amp;rdquo; answer here. One solution, given our constraints, is to combine Cognito with our IdP federation, but since I don&amp;rsquo;t control our company&amp;rsquo;s IdP tenant, I couldn&amp;rsquo;t get that working. Instead, I deployed &lt;strong>two separate ECS services&lt;/strong>, one read-write and one read-only, each behind its own ALB target group and listener rule.&lt;/p>
&lt;p>&lt;strong>Read-write webserver command:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">dagster-webserver -h 0.0.0.0 -p &lt;span class="m">3000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -w /opt/dagster/dagster_home/workspace.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -l /pipelines/admin&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Read-only webserver command:&lt;/strong>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">dagster-webserver -h 0.0.0.0 -p &lt;span class="m">3000&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -w /opt/dagster/dagster_home/workspace.yaml &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> -l /pipelines &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --read-only&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>-l&lt;/code> flag sets the URL prefix. The ALB rules do the routing:&lt;/p>
&lt;ul>
&lt;li>&lt;code>/pipelines/admin*&lt;/code> → read-write target group (higher priority)&lt;/li>
&lt;li>&lt;code>/pipelines*&lt;/code> → read-only target group (lower priority)&lt;/li>
&lt;/ul>
&lt;p>Both rules are Cognito-authenticated, so anyone hitting either URL has to log in. Scientists get the read-only URL. Engineers get the admin URL. Simple, but it works.&lt;/p>
&lt;h3 id="two-actions-per-listener-rule">Two actions per listener rule&lt;/h3>
&lt;p>An authenticated listener rule is two actions in order. The &lt;code>authenticate-cognito&lt;/code> action runs first, then the &lt;code>forward&lt;/code> action sends the request to the target group. If you only write the forward action, the rule works and nobody has to log in, which is the kind of mistake you don&amp;rsquo;t notice until someone tells you they never saw a login screen.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_lb_listener_rule&amp;#34; &amp;#34;pipelines_admin_rw&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> listener_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_alb_listener&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">local&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">admin_listener_priority&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;authenticate-cognito&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">authenticate_cognito&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_client_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool_client&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> user_pool_domain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_cognito_user_pool&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">domain&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> on_unauthenticated_request&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;authenticate&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> scope&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;openid&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> session_timeout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3600&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">action&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;forward&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> target_group_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_lb_target_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_rw&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">path_pattern&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;/${var.alb_rule_listener_rule_path}/admin*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The read-only rule is identical except for the path pattern and target group, plus a &lt;code>depends_on&lt;/code> pointing at the admin rule so the two are never created in the wrong order.&lt;/p>
&lt;h3 id="finding-an-open-listener-priority">Finding an open listener priority&lt;/h3>
&lt;p>ALB listener rule priorities have to be unique across the whole listener, and they need to be &lt;em>specified&lt;/em>. Neither Terraform nor AWS will &amp;ldquo;infer&amp;rdquo; the next open value. Our ALB is shared with other applications, so hardcoding numbers means constantly checking what&amp;rsquo;s available in the console or through the CLI. I ended up writing a small bash script that the &lt;code>external&lt;/code> data source calls at &lt;code>terraform plan&lt;/code> time. It lists the existing rules and returns the first &lt;code>N&lt;/code> unused priority slots:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;external&amp;#34; &amp;#34;listener_priorities&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> program&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;bash&amp;#34;, &amp;#34;${path.module}/external/next_listener_priorities.sh&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> query&lt;/span> &lt;span class="o">=&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> listener_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_alb_listener&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> region&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_region&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">current&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> profile&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_profile&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> count&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> admin_listener_priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">tonumber&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">external&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">listener_priorities&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">priority_1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> ro_listener_priority&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">tonumber&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">external&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">listener_priorities&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">result&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">priority_2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And the script itself, which is a &lt;code>jq&lt;/code> expression over &lt;code>describe-rules&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/usr/bin/env bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="nb">set&lt;/span> -euo pipefail
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">INPUT_JSON&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>cat&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">LISTENER_ARN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.listener_arn&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">REGION&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.region&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">COUNT&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>jq -r &lt;span class="s1">&amp;#39;.count // 2&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$INPUT_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">RULES_JSON&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="k">$(&lt;/span>aws --region &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$REGION&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> elbv2 describe-rules &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --listener-arn &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$LISTENER_ARN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> --output json&lt;span class="k">)&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find the first COUNT missing priorities in [1..50000]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">jq -c --argjson count &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$COUNT&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> [ .Rules[].Priority | select(. != &amp;#34;default&amp;#34;) | tonumber ] | unique as $used
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | reduce range(1; 50001) as $i
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> ({out: []};
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> if (.out | length) &amp;gt;= $count then .
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> elif ($used | index($i)) == null then .out += [$i]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> else . end
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> )
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | .out as $m
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> | reduce range(0; ($m | length)) as $idx
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> ({}; . + {(&amp;#34;priority_&amp;#34; + (($idx + 1) | tostring)): ($m[$idx] | tostring)})
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span> &lt;span class="o">&amp;lt;&amp;lt;&amp;lt;&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$RULES_JSON&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>external&lt;/code> data source runs on every plan, so the machine running Terraform needs the AWS CLI and &lt;code>jq&lt;/code> installed and credentials that can call &lt;code>describe-rules&lt;/code> (this could be your local machine, your CI/CD runner, an EC2 instance, etc.) The priorities are read at plan time, so if someone else claims a slot between your plan and your apply, the apply fails, though that&amp;rsquo;s rare enough that I&amp;rsquo;ve been willing to live with it. Basically, hardcoding priorities was not an option I wanted to entertain.&lt;/p>
&lt;h3 id="health-checks-and-alb-path-prefixes">Health checks and ALB path prefixes&lt;/h3>
&lt;p>The ALB health checks for the webservers need to use the correct path prefix. If your webserver is mounted at &lt;code>/pipelines&lt;/code> and your health check pings &lt;code>/&lt;/code>, it&amp;rsquo;ll get a redirect (or a 404) and the target will never go healthy.&lt;/p>
&lt;p>Read-only health check path: &lt;code>/&amp;lt;your-prefix&amp;gt;&lt;/code>
Read-write health check path: &lt;code>/&amp;lt;your-prefix&amp;gt;/admin&lt;/code>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">health_check&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;HTTP&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;/${var.alb_rule_listener_rule_path}/admin&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> matcher&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;200-399&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> healthy_threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> unhealthy_threshold&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> interval&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">30&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> timeout&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">25&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Set the matcher to &lt;code>200-399&lt;/code> to handle redirects during startup. The Dagster webserver takes 15 to 30 seconds to come up on a cold start, so &lt;code>unhealthy_threshold&lt;/code> and &lt;code>interval&lt;/code> need to be generous enough that a slow container start doesn&amp;rsquo;t put the service into a restart loop.&lt;/p>
&lt;hr>
&lt;h2 id="dagsteryaml-the-compute-and-storage-specs">dagster.yaml: the compute and storage specs&lt;/h2>
&lt;p>The &lt;code>dagster.yaml&lt;/code> configuration basically let&amp;rsquo;s you define job queing, which system will run your jobs (here, ECS), where logs and compute history will be stored, etc. Here&amp;rsquo;s what our final config looks like (pretty similar to what&amp;rsquo;s already out there):&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">run_coordinator&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster.core.run_coordinator&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">QueuedRunCoordinator&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">max_concurrent_runs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">15&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">scheduler&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster.core.scheduler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DagsterDaemonScheduler&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">run_launcher&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_aws.ecs&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">EcsRunLauncher&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">use_current_ecs_task_config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">include_sidecars&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">run_task_kwargs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cluster&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-ecs-cluster-name&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">compute_logs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_aws.s3.compute_log_manager&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">S3ComputeLogManager&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">bucket&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_LOG_BUCKET&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">prefix&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;compute_logs&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">region&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;your-aws-region&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">run_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.run_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresRunStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">schedule_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.schedule_storage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresScheduleStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">event_log_storage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">module&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dagster_postgres.event_log&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">class&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">PostgresEventLogStorage&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">config&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">postgres_db&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">hostname&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_HOSTNAME }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">username&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_USER }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">password&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_PASSWORD }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">db_name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>{&lt;span class="w"> &lt;/span>&lt;span class="nt">env&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">DAGSTER_POSTGRES_DB }&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">port&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">5432&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">retention&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schedule&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">purge_after_days&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">90&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">sensor&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">purge_after_days&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">skipped&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">7&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">failure&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">30&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">success&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>-&lt;span class="m">1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The ECS task configuration was confusing to me initially. &lt;strong>&lt;code>use_current_ecs_task_config: true&lt;/code>&lt;/strong> tells the EcsRunLauncher to take the network configuration (subnets, security groups, cluster) from the task that requested the run rather than making you restate all of it in &lt;code>run_task_kwargs&lt;/code>. Combined with a per-pipeline run task definition, which is covered in part two, it means each pipeline&amp;rsquo;s runs launch into the same networking as that pipeline&amp;rsquo;s code server, with that pipeline&amp;rsquo;s own IAM roles. You don&amp;rsquo;t need to define a shared task definition across pipelines, which keeps the principle of least privilege in play.&lt;/p>
&lt;p>Every credential in that file is an &lt;code>env:&lt;/code> reference, and nothing secret is in the file itself.&lt;/p>
&lt;hr>
&lt;h2 id="where-the-platform-config-lives">Where the platform config lives&lt;/h2>
&lt;p>There are two config files: &lt;code>dagster.yaml&lt;/code> (instance config, above) and &lt;code>workspace.yaml&lt;/code> (the list of code locations the Daemon and Webserver load over gRPC). Baking config into the Docker image means rebuilding and redeploying the image every time you tweak a config value. For the Daemon and Webserver that&amp;rsquo;s a bad idea, because those images almost never change, but the config does. So Terraform writes both files into SSM parameters:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ssm_parameter&amp;#34; &amp;#34;dagster_yaml&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.ssm_prefix}/dagster.yaml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;String&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_ssm_parameter&amp;#34; &amp;#34;workspace_yaml&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.ssm_prefix}/workspace-${var.platform_env}.yaml&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;String&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">workspace_yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>and the task definitions inject them as container secrets:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">locals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> dagster_config_ssm_secrets&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_YAML&amp;#34;, valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ssm_parameter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_yaml&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;WORKSPACE_YAML&amp;#34;, valueFrom&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_ssm_parameter&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">workspace_yaml&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> common_platform_env&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_CONFIG_FROM_ENV&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;1&amp;#34;&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_DEPLOYMENT_ENV&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">platform_env&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_HOME&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_home&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> { name&lt;/span> &lt;span class="o">=&lt;/span>&lt;span class="n"> &amp;#34;DAGSTER_LOG_BUCKET&amp;#34;, value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.dagster_log_bucket}-${var.platform_env}&amp;#34;&lt;/span> }&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>DAGSTER_CONFIG_FROM_ENV&lt;/code> is a flag the entrypoint script checks. When it&amp;rsquo;s set, the script writes &lt;code>$DAGSTER_YAML&lt;/code> and &lt;code>$WORKSPACE_YAML&lt;/code> out to &lt;code>$DAGSTER_HOME/dagster.yaml&lt;/code> and &lt;code>$DAGSTER_HOME/workspace.yaml&lt;/code> before starting the process, and leaves whatever is in the image alone otherwise. Changing a config value is then an SSM update and a service restart, not a full image rebuild and redeployment.&lt;/p>
&lt;p>The ECS &lt;code>secrets&lt;/code> block works with plain SSM &lt;code>String&lt;/code> parameters, not just &lt;code>SecureString&lt;/code> and Secrets Manager, and standard SSM parameters are capped at 4KB of value, so a &lt;code>workspace.yaml&lt;/code> with a lot of code locations will eventually need an advanced parameter (8KB) or a different mechanism. Pipeline containers do the opposite and ship their config in the image. That&amp;rsquo;s covered in
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part Two&lt;/a>. I also changed my mind about it after writing these posts, so I add some thoughts about that as well.&lt;/p>
&lt;hr>
&lt;h2 id="networking-the-part-that-took-me-the-longest">Networking: the part that took me the longest&lt;/h2>
&lt;p>I found the networking stuff to be the most confusing. Multiple features all have to be correct before anything communicates to anything else.&lt;/p>
&lt;h3 id="what-talks-to-what">What talks to what&lt;/h3>
&lt;p>Before any of the Terraform, it was worthwhile to me to write the traffic down explicitly, because the number of distinct flows is small and each flow has to be allowed somewhere:&lt;/p>
&lt;pre>&lt;code class="language-mermaid">graph LR
Browser(Browser)
ALB{ALB}
RW[webserver-rw]
RO[webserver-ro]
Daemon[daemon]
Uc[usercode]
Run[run task]
CM(Cloud Map)
ECSAPI(ECS API)
PG(Postgres)
S3(S3)
Browser --&amp;gt;|443| ALB
ALB --&amp;gt;|&amp;#34;/admin*&amp;#34;| RW
ALB --&amp;gt;|&amp;#34;/*&amp;#34;| RO
RW --&amp;gt;|4000| Uc
RO --&amp;gt;|4000| Uc
Daemon --&amp;gt;|4000| Uc
Daemon --&amp;gt;|RunTask| ECSAPI
ECSAPI --&amp;gt;|launches| Run
Uc -.-&amp;gt;|register| CM
CM -.-&amp;gt;|resolve| Daemon
CM -.-&amp;gt;|resolve| RW
RW --&amp;gt;|5432| PG
Daemon --&amp;gt;|5432| PG
Uc --&amp;gt;|5432| PG
Run --&amp;gt;|5432| PG
Run --&amp;gt;|443| S3
classDef platform fill:#e8f0fe,stroke:#4285f4
classDef pipeline fill:#e6f4ea,stroke:#34a853
classDef ephemeral fill:#fef7e0,stroke:#f9ab00
classDef backend fill:#f1f3f4,stroke:#9aa0a6
class RW,RO,Daemon platform
class Uc pipeline
class Run ephemeral
class CM,ECSAPI,PG,S3 backend&lt;/code>&lt;/pre>&lt;p>Blue is the platform module, green is a pipeline, amber is the ephemeral run task, grey is something AWS runs for you. Solid arrows are request traffic, labelled with the port. Dotted arrows are service discovery, which carries no application data. Here is the same thing as a table:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>From&lt;/th>
&lt;th>To&lt;/th>
&lt;th>Port&lt;/th>
&lt;th>Why&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>ALB&lt;/td>
&lt;td>Webserver&lt;/td>
&lt;td>3000 (&lt;code>webserver_port&lt;/code>)&lt;/td>
&lt;td>Serving the UI&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Webserver&lt;/td>
&lt;td>Usercode server&lt;/td>
&lt;td>4000&lt;/td>
&lt;td>Loading code locations over gRPC&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>Usercode server&lt;/td>
&lt;td>4000&lt;/td>
&lt;td>Polling schedules and sensors&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>ECS API&lt;/td>
&lt;td>443&lt;/td>
&lt;td>&lt;code>RunTask&lt;/code> to launch runs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon&lt;/td>
&lt;td>EC2 API&lt;/td>
&lt;td>443&lt;/td>
&lt;td>&lt;code>DescribeNetworkInterfaces&lt;/code> to resolve task IPs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon, Webserver, Usercode, Run&lt;/td>
&lt;td>RDS Postgres&lt;/td>
&lt;td>5432&lt;/td>
&lt;td>Run storage, event log, schedule storage&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Daemon, Webserver, Usercode, Run&lt;/td>
&lt;td>S3&lt;/td>
&lt;td>443&lt;/td>
&lt;td>Compute logs&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>All tasks (at startup)&lt;/td>
&lt;td>ECR, Secrets Manager, SSM, CloudWatch Logs&lt;/td>
&lt;td>443&lt;/td>
&lt;td>Pulling the image, injecting secrets and config&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The Daemon never receives inbound traffic from anything, so its security group needs no ingress rules at all.&lt;/p>
&lt;h3 id="three-security-groups">Three security groups&lt;/h3>
&lt;p>The platform module creates all three security groups, including the one used by pipelines. The usercode security group uses security group references, not CIDR blocks, to restrict inbound port 4000 to &lt;em>only&lt;/em> the Daemon and Webserver, so nothing else in the VPC can reach a code server. While a CIDR rule like &lt;code>10.0.0.0/16&lt;/code> would work too, doing so means anything that gets a private IP in that range can open a gRPC connection to your code servers, and you don&amp;rsquo;t want that. A code server could tell any caller what jobs exist and let them be launched. Referencing the daemon and webserver security groups instead means the rule stays correct as subnets change and there&amp;rsquo;s no CIDR math to get wrong.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/vpc.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Daemon: outbound-only. It talks to ECS APIs and usercode servers, never receives inbound.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;daemon&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster Daemon. Outbound only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Webserver: inbound from ALB only, on the webserver port.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;webserver&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-webserver-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster webservers (RO+RW). Inbound from ALB only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">ingress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Inbound from ALB security group&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver_port&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> security_groups&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">alb_sg_id&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1"> # reference your existing ALB SG here
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Usercode servers: inbound TCP 4000 from daemon and webserver SGs only.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># This SG is shared across all pipeline usercode services.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_security_group&amp;#34; &amp;#34;usercode&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-usercode-sg-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Dagster usercode. Allow TCP 4000 from daemon and webserver only.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc_id&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">ingress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;gRPC from daemon and webserver&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">4000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">4000&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;tcp&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> security_groups&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">aws_security_group&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">webserver&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">egress&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow all outbound&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> from_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> to_port&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="m">0&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> protocol&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;-1&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> cidr_blocks&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;0.0.0.0/0&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The usercode security group is defined here and looked up by name in each pipeline module, so the platform owns the definition and pipelines just join it.&lt;/p>
&lt;h3 id="how-do-tasks-get-their-networking">How do tasks get their networking?&lt;/h3>
&lt;p>The Dagster documentation unfortunately does not describe this clearly. There is no security group anywhere in the Terraform for run tasks. So how do they run? As I mentioned above, the answer is &lt;code>use_current_ecs_task_config: true&lt;/code> in &lt;code>dagster.yaml&lt;/code>. When the EcsRunLauncher launches a run, it reads the network configuration of the task making the request (the usercode code server) and reuses it. Run tasks land in the same subnets and the same usercode security group as the code server that spawned them automatically. Meaning:&lt;/p>
&lt;ul>
&lt;li>A run task inherits inbound 4000 from the daemon and webserver&lt;/li>
&lt;li>A run task needs the same &lt;strong>egress&lt;/strong> as the code server: Postgres to write events, S3 for compute logs, and everything in the startup list below. Egress is where run tasks actually fail, never ingress.&lt;/li>
&lt;li>If you lock the usercode security group&amp;rsquo;s egress down, you&amp;rsquo;re locking down runs too, and the failure will look like a job that starts and then hangs rather than a network error.&lt;/li>
&lt;/ul>
&lt;p>Each pipeline&amp;rsquo;s runs get that pipeline&amp;rsquo;s networking and that pipeline&amp;rsquo;s IAM roles, with nothing shared and nothing restated. The pipeline&amp;rsquo;s tasks are inheriting the networking rules defined for that whole pipeline.&lt;/p>
&lt;h3 id="private-subnets-need-a-path-to-aws-apis">Private Subnets Need a Path to AWS APIs&lt;/h3>
&lt;p>All of these tasks run in private subnets with &lt;code>assign_public_ip = false&lt;/code>. A Fargate task in a private subnet still has to reach several AWS endpoints over the AWS API surface before that container code runs at all: ECR to pull the image (both the API and the layer storage in S3), Secrets Manager and SSM to inject secrets and config, and CloudWatch Logs to write those outputs. That traffic needs either a NAT gateway in the route table or VPC interface endpoints for each of those services, plus an S3 gateway endpoint. Our VPC already had a NAT gateway, so I leveraged that. If that path is missing, the task doesn&amp;rsquo;t fail in a way that mentions networking. It sits in &lt;code>PROVISIONING&lt;/code> or &lt;code>PENDING&lt;/code>, then stops with a &lt;code>ResourceInitializationError&lt;/code> about being unable to pull the image or retrieve a secret. Nothing in the container logs, because the container never started, and nothing in the Dagster UI, because Dagster never got far enough to know about it.&lt;/p>
&lt;h3 id="what-each-failure-actually-looks-like">What Each Failure Actually Looks Like&lt;/h3>
&lt;p>This table contains rough debugging notes I wish I&amp;rsquo;d had:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Symptom&lt;/th>
&lt;th>Usual cause&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>Task stuck in &lt;code>PROVISIONING&lt;/code>, then &lt;code>ResourceInitializationError&lt;/code>&lt;/td>
&lt;td>No egress path from the private subnet. Missing NAT route or VPC endpoints&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code location shows as failed in the UI, gRPC deadline exceeded&lt;/td>
&lt;td>Usercode SG isn&amp;rsquo;t allowing 4000 from the webserver SG&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Schedules and sensors never tick, but the UI loads fine&lt;/td>
&lt;td>Same rule, but the daemon SG. The webserver and daemon are separate sources&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Code location resolves intermittently after a deploy&lt;/td>
&lt;td>Cloud Map DNS TTL still serving the old task&amp;rsquo;s IP. See part two&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>ALB target never goes healthy&lt;/td>
&lt;td>Health check path missing the URL prefix, or the matcher rejecting a redirect&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Runs launch and immediately fail with no logs&lt;/td>
&lt;td>IAM, not networking. Usually the missing &lt;code>ecs:TagResource&lt;/code>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Run starts, then hangs without writing events&lt;/td>
&lt;td>Egress from the usercode SG. Run tasks inherit it&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>Generally, if you&amp;rsquo;re seeing an error, I&amp;rsquo;d start with examining the IAM roles first.&lt;/p>
&lt;hr>
&lt;h2 id="service-discovery-the-namespace">Service Discovery: The Namespace&lt;/h2>
&lt;p>The Daemon needs to find each pipeline&amp;rsquo;s code server. On ECS Fargate, the standard approach is AWS Cloud Map (private DNS). The platform creates the namespace, and each pipeline creates its own service record within it.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/cloud_map.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_service_discovery_private_dns_namespace&amp;#34; &amp;#34;dagster&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;pipelines-${var.env}.usercode&amp;#34; # e.g. &amp;#34;pipelines-prod.usercode&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Private DNS namespace for Dagster usercode servers&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> vpc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">var&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">vpc_id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Two requirements come with a private DNS namespace. It&amp;rsquo;s bound to exactly one VPC, so a multi-VPC setup needs a namespace per VPC (which is part of why the name carries the environment). And the VPC needs both &lt;code>enableDnsSupport&lt;/code> and &lt;code>enableDnsHostnames&lt;/code> turned on, or the records resolve from nothing and every code location fails to load with what looks like a connection error rather than a DNS one.&lt;/p>
&lt;hr>
&lt;h2 id="iam-probably-the-crux-of-everything">IAM: Probably the crux of everything&lt;/h2>
&lt;p>There are two IAM roles per service: an &lt;strong>execution role&lt;/strong> (used by ECS to pull images, write logs, and fetch secrets before the container starts) and a &lt;strong>task role&lt;/strong> (used by the running container to call AWS APIs at runtime). Regarding AWS secrets: if the value is injected by the ECS &lt;code>secrets&lt;/code> block in the task definition, the &lt;strong>execution&lt;/strong> role needs to read it, because &lt;em>something&lt;/em> needs to inject it into the task definition. If your application code calls &lt;code>boto3&lt;/code> to fetch it, the &lt;strong>task&lt;/strong> role needs to read it. Yes, I was confused initially too.&lt;/p>
&lt;h3 id="the-shared-trust-policy">The Shared Trust Policy&lt;/h3>
&lt;p>All ECS task roles share the same trust policy:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_task_assume_role&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;sts:AssumeRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">principals&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> type&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Service&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> identifiers&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="execution-roles-webserver--daemon">Execution Roles (Webserver + Daemon)&lt;/h3>
&lt;p>Execution roles need the standard ECS managed policy, plus access to Secrets Manager and SSM so ECS can inject those values before the container starts:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># dagster-platform/iam.tf
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;daemon_execution&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-role-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy_attachment&amp;#34; &amp;#34;daemon_exec_attach&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">name&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy_arn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Execution role needs Secrets Manager access so ECS can inject secrets into the container
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_exec_db_secret&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-db-secret-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_db_secret_read&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Execution role needs SSM access to inject dagster.yaml and workspace.yaml as env vars
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_exec_ssm&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-exec-ssm-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_execution&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">dagster_ssm_read&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The webserver execution role is identical in structure.&lt;/p>
&lt;h3 id="daemon-task-role">Daemon Task Role&lt;/h3>
&lt;p>The Daemon&amp;rsquo;s task role needs to launch, describe, stop, and tag ECS run tasks. Dagster tags every run task it spawns with run metadata, and if the permission is absent, runs will launch and then immediately fail.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role&amp;#34; &amp;#34;daemon_task&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-task-role-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> assume_role_policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_task_assume_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Allow launching run tasks for any task def matching &amp;#34;pipeline-*-run-*&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Scoped to the specific cluster so the daemon can&amp;#39;t escape to other clusters
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_run_tasks&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;RunDescribeStopRunTasks&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:RunTask&amp;#34;, &amp;#34;ecs:DescribeTasks&amp;#34;, &amp;#34;ecs:StopTask&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task-definition/pipeline-*-run-*:*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ArnEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;ecs:cluster&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_ecs_cluster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">cluster&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">arn&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # DescribeTaskDefinition has no resource-level support, must be &amp;#34;*&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;DescribeTaskDefinitions&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:DescribeTaskDefinition&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_ecs_run&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-ecs-run-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_run_tasks&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Allow tagging ECS tasks. Without this, Dagster run tasks fail silently at launch.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_tag_resource&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;AllowTagEcsTasks&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs:TagResource&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;arn:aws:ecs:${var.region}:${data.aws_caller_identity.current.account_id}:task/${var.cluster_name}/*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_ecs_tag&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-ecs-tag-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_tag_resource&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># The daemon also needs ec2:DescribeNetworkInterfaces to resolve Fargate task IPs
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_describe_enis&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-describe-enis-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ec2_describe_enis&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="passrole-contract">PassRole Contract&lt;/h3>
&lt;p>The last piece of the daemon&amp;rsquo;s task role is the one that ties the two modules together. To launch a run task, the daemon has to pass that task&amp;rsquo;s execution and task roles to ECS, and THOSE roles live in the pipeline module, not here. The naive version grants &lt;code>iam:PassRole&lt;/code> on &lt;code>*&lt;/code>, which hands the daemon the ability to pass any role in the account to ECS, but that doesn&amp;rsquo;t follow the principle of least priviledge. Instead, I scoped it with tag conditions:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-hcl" data-lang="hcl">&lt;span class="line">&lt;span class="cl">&lt;span class="k">data&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_policy_document&amp;#34; &amp;#34;ecs_pass_dagster_roles&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">statement&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> sid&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;PassOnlyDagsterRunRoles&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> effect&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;Allow&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> actions&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;iam:PassRole&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> resources&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;*&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="c1"> # resource-level not supported for PassRole; use tag conditions instead
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:PassedToService&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ecs-tasks.amazonaws.com&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }&lt;span class="c1">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"> # Only allow passing roles tagged as dagster pipeline components
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:ResourceTag/dagster:component&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;pipeline&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">condition&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> test&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;StringEquals&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> variable&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;iam:ResourceTag/dagster:managed-by&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> values&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;terraform&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> }
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">resource&lt;/span> &lt;span class="s2">&amp;#34;aws_iam_role_policy&amp;#34; &amp;#34;daemon_pass_roles&amp;#34;&lt;/span> {
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> name&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;${var.name_prefix}-daemon-pass-run-roles-${var.env}&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> role&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">aws_iam_role&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">daemon_task&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">id&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n"> policy&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">data&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">aws_iam_policy_document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">ecs_pass_dagster_roles&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="k">json&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">}&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>iam:PassRole&lt;/code> doesn&amp;rsquo;t support resource-level scoping the way you&amp;rsquo;d want, so &lt;code>&amp;quot;*&amp;quot;&lt;/code> plus a strict tag condition is the practical answer. That means that you can add new pipelines without ever touching the platform&amp;rsquo;s IAM policies. As long as a new pipeline&amp;rsquo;s run roles carry the right tags, the daemon can pass them.&lt;/p>
&lt;hr>
&lt;h2 id="what-the-platform-expects-from-a-pipeline">What the Platform Expects From a Pipeline&lt;/h2>
&lt;p>In actuality, the tag condition is really a contract. To plug into this Dagster platform, a pipeline has to:&lt;/p>
&lt;ol>
&lt;li>Register a service in the Cloud Map namespace, so the Daemon and Webserver can resolve it by DNS on port 4000.&lt;/li>
&lt;li>Attach its usercode service to the platform&amp;rsquo;s usercode security group, so that traffic is actually allowed.&lt;/li>
&lt;li>Tag its run roles with &lt;code>dagster:component&lt;/code> and &lt;code>dagster:managed-by&lt;/code>, so the Daemon is permitted to pass them to ECS.&lt;/li>
&lt;li>Get its code location into &lt;code>workspace.yaml&lt;/code> in SSM, so the Webserver and Daemon know it exists.&lt;/li>
&lt;/ol>
&lt;p>
&lt;a href="https://kristianeschenburg.netlify.app/post/deploying-dagster-to-ecs-2-pipelines/">Part two&lt;/a> covers the module that does all four, plus the two-task-definition pattern, secrets, and how deploys actually roll a new image onto ECS.&lt;/p></description></item><item><title>Building a Schema Registry from Scratch for a Scientific Data Platform</title><link>https://kristianeschenburg.netlify.app/post/schema-registry/</link><pubDate>Tue, 24 Jun 2025 09:00:00 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/schema-registry/</guid><description>&lt;p>When I joined Just-Evotec Biologics (first as a data scientist, now as a data platform engineer), I inherited a data ecosystem that probably looked pretty familiar to what lots of others have dealth with: a dozen scientific instruments each outputting data in proprietary formats, a LIMS system with its own schema, upstream and downstream experimental systems, Excel workbooks scientists had been maintaining for years but hidden away from any production storage system, and a swathe of applications that had been developed previously but had very little in the way of data governance. We didn&amp;rsquo;t have a central schema definitions, very little in the way of contracts between producers (e.g. instrumentation) and consumers (e.g. scientists, applications, clients, more instruments), and no way to ask or answer &amp;ldquo;what&amp;rsquo;s being pulled down here?&amp;rdquo;.&lt;/p>
&lt;p>I&amp;rsquo;ve been slowly building up our data platform, including implementing a formal schema registry. Here, I&amp;rsquo;ll cover some core design decisions I made and patterns that worked for me for the registry. The implementation is domain (and in our case, functional group and stakeholder) agnostic.&lt;/p>
&lt;hr>
&lt;h2 id="why-not-just-use-an-existing-registry-tool">Why not just use an existing registry tool?&lt;/h2>
&lt;p>Some alternative solutions to implementing / using a schema registry include Confluent&amp;rsquo;s Schema Registry, AWS Glue, Great Expectations, etc. Confluent&amp;rsquo;s Schema Registry is excellent for Kafka-based event streams, AWS Glue has a data catalog, and Great Expectations handles data quality well (just like Pandera). But none of those really addressed all or most of my needs, or fit in with my environment: a mix of validated DataFrames, LIMS API outputs, Parquet files in S3, and a team of scientists, some of whom wrote Python but weren&amp;rsquo;t data engineers. I wanted the following:&lt;/p>
&lt;ol>
&lt;li>Define schemas as Python classes with static type annotations&lt;/li>
&lt;li>Automatically serialize those classes to human-readable YAML&lt;/li>
&lt;li>Query schemas by metadata (tier, data lineage stage, source system)&lt;/li>
&lt;li>Reconstruct a live Pandera model from a stored YAML definition at runtime&lt;/li>
&lt;/ol>
&lt;p>We&amp;rsquo;re primarily working with data tables (as opposed to events), so I wanted a coupling between a type agnostic representation like YAML and Pandera. However, I&amp;rsquo;ve since extended a lot of this functionality for event-based schemas. At some point, I might want to revisit Glue again so I don&amp;rsquo;t reinvent the wheel.&lt;/p>
&lt;hr>
&lt;h2 id="core-architecture">Core architecture&lt;/h2>
&lt;p>The general schema development flow is: &lt;strong>write Python → export YAML → publish to S3 → consume via the registry client at runtime&lt;/strong>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">author a Pandera contract (contracts/domains/**/schema.py)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → export-models-to-yaml # Python model to canonical YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → upload-yaml-to-s3 # publish, through the canonical-dtype gate
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> S3 (what consumers actually read)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → REGISTRY.get_pandera_model(name) # at runtime, in a pipeline
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> → a live Pandera DataFrameModel&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The important property of this diagram is the direction of the arrow into S3. Python is how schemas are &lt;em>authored&lt;/em>, but S3 is what consumers &lt;em>read&lt;/em>. A pipeline never imports a contract class. It fetches YAML and builds a Pandera model from it at runtime, which means a schema change propagates by re-publishing to S3, with no rebuild or redeploy of any consumer. I initially didn&amp;rsquo;t build this in, and found myself needed to re-install the registry every time I authored a new contract. Not ideal.&lt;/p>
&lt;p>The repository is a monorepo of three independently versioned pieces:&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Piece&lt;/th>
&lt;th>What it is&lt;/th>
&lt;th>Who uses it&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>the service&lt;/td>
&lt;td>Contract definitions, templates, source-system specs, the FastAPI API, and the export/publish tooling&lt;/td>
&lt;td>Deployed as a Lambda. Not installed as a library.&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the client&lt;/td>
&lt;td>Fetch schemas from S3 and deserialize them to Pandera models, plus the canonical dtype vocabulary&lt;/td>
&lt;td>Pipelines, and anything validating data against a schema (e.g. dashboards)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>the data IO package&lt;/td>
&lt;td>Connectors for the LIMS and for the parquet/delta lakehouse&lt;/td>
&lt;td>Pipelines&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>I started with one package and split it once pipelines started depending on it. A pipeline that just wants to validate a DataFrame should not be installing FastAPI, &lt;code>jsonschema&lt;/code>, and the whole publish toolchain to do it. The client should be thin, and the dependency direction one-directional a.k.a. the service depends on the client.&lt;/p>
&lt;h3 id="repository-layout">Repository layout&lt;/h3>
&lt;p>The code tree looks like this. Names are genericized so as to hide our proprietary namespaces and functionality, but the shape is exactly what we run:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">schema-registry/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── registry/ # the service
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── contracts/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── templates/ # JSON Schema per schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── api/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── domains/ # the contracts themselves
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── lab/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── analytics/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ ├── schema.py # authored
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ │ └── schema.yaml # generated, committed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── v2_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ ├── schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ │ └── schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ ├── operations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ │ └── enterprise/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── utilities/dataframe/export.py # Python model → YAML
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── integrations/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── source_specs/ # contract → physical source table
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── lab/analytics/cell_count/v1_0/spec.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── api/ # FastAPI app (deployed as a Lambda)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── app.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ ├── auth.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── routers/{domains,schemas,specs}.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── publish/sync_schemas.py # validation gates + S3 upload
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── scripts/ # the two CLI entry points
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── packages/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├── schema-registry-client/ # what pipelines + dashboards install
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── schema_registry_client/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── contracts/utilities/dataframe/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── dtypes.py # the canonical vocabulary
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── mixins.py # SchemaInfo, SchemaMeta, ContractMixin (e.g. constants + fields applied to all schemas)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ ├── registration.py # the decorator + path checks
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │ └── base.py # base DataFrameModel configs
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── io/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── registry.py # the consumer-facing Registry
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ├── backends/s3.py # index-backed S3 reads
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ └── adapters/ # YAML → live Pandera model
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── data-io/ # source-system and lakehouse connectors&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>I wanted to be able to seperate the client from the registry and contracts, so I want to emphasize which side of the &lt;code>packages/&lt;/code> line each concern lives on. Everything a &lt;em>consumer&lt;/em> needs, the dtype vocabulary, the identity dataclasses, the deserializer, sits in the client. Everything about &lt;em>producing&lt;/em> the registry, discovery, export, validation gates, upload, and the API, sits in the service. The decorator lives in the client rather than the service, which looks odd at first, but contracts are authored against it and the deserializer needs the same &lt;code>SchemaInfo&lt;/code> shape when it rebuilds a model, so it belongs on the shared side.&lt;/p>
&lt;p>The generated &lt;code>schema.yaml&lt;/code> sitting next to the authored &lt;code>schema.py&lt;/code> and getting committed is intentional. A schema change shows up in code review as a readable diff against the previous commit of the actual YAML contract, not just as a Python class that someone would have to mentally compile.&lt;/p>
&lt;h3 id="adding-a-schema-end-to-end">Adding a schema, end to end&lt;/h3>
&lt;p>The whole process for a contributor is only five steps:&lt;/p>
&lt;ol>
&lt;li>Create &lt;code>contracts/domains/&amp;lt;domain&amp;gt;/&amp;lt;subdomains&amp;gt;/&amp;lt;entity&amp;gt;/v1_0/schema.py&lt;/code> and write the Pandera model, decorated with &lt;code>@register_dataframe_schema&lt;/code>.&lt;/li>
&lt;li>If it maps to a physical source table, add the matching spec under &lt;code>integrations/source_specs/&lt;/code>.&lt;/li>
&lt;li>Run the export command. It walks the tree, checks that the declared name and version match the directory path, and writes &lt;code>schema.yaml&lt;/code> next to each &lt;code>schema.py&lt;/code>.&lt;/li>
&lt;li>Commit both files. CI re-runs the export and fails the build if anything is out of date.&lt;/li>
&lt;li>Run the upload command. It validates every document against its template, runs the canonical dtype gate, uploads to S3, and rewrites the index.&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">poetry run export-models-to-yaml &lt;span class="c1"># Python models → canonical schema.yaml&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">poetry run upload-yaml-to-s3 &lt;span class="c1"># validate, publish, reindex&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Consumers of the registry pick the changes up on their next run, without any intermediate rebuilds or redeploys. So the rest of my post is mostly about what happens inside steps 3 and 5.&lt;/p>
&lt;hr>
&lt;h2 id="schema-identity-the-domainsubdomainentity-pattern">Schema identity: the domain/subdomain/entity pattern&lt;/h2>
&lt;p>I was very opinionated and rigid about the &lt;em>structure&lt;/em> of the registry, and not just about how to define the contracts themselves. Every schema gets a unique dotted name composed of domain, zero or more subdomains, and an entity name:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl"> * lab.analytics.cell_count
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * lab.upstream.bioreactor_run
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * operations.equipment.instrument
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> * enterprise.finance.purchase_order&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This naming scheme is enforced structurally. Schemas live at file paths that mirror their names:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">registry/contracts/domains/lab/analytics/cell_count/v1_0/schema.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>@register_dataframe_schema&lt;/code> decorator captures the identity at class definition time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lab&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;analytics&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;LIMS cell count assay table.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">meta&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">SchemaMeta&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">alignment&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">LIMSCellCountSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">LIMSBaseSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_row_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">sample_id&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">str&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">vcd&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">viability&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">float&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run_date&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Timestamp&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">Field&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">nullable&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">coerce&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The decorator is thin and only builds a frozen &lt;code>SchemaInfo&lt;/code>, derives the dotted name from its parts, and stamps both onto the class:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_register&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">info&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">SchemaInfo&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">domain&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">domain&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subdomains&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">subdomains&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entity&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">entity&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">owner&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">owner&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">description&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_info__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">info&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">cls&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">__schema_meta__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">meta&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">SchemaMeta&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="bp">cls&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>SchemaInfo.__post_init__&lt;/code> does a quick n&amp;rsquo; dirty validation by rejecting any domain, subdomain, or entity containing &lt;code>.&lt;/code>, &lt;code>/&lt;/code>, or &lt;code>-&lt;/code>, since those would corrupt the dotted name or the path it maps to, and then joins the parts into &lt;code>name&lt;/code>. The more expensive checks, that the folder version matches the declared version (&lt;code>v1_0&lt;/code> → &lt;code>1.0&lt;/code>) and that the directory path matches the dotted name, run at &lt;strong>export time&lt;/strong>, not at import time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_domain_name&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_name_matches_module&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">name&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="n">RegistryConfig&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">enforce_path_version&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">confirm_schema_version_matches_module_version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="bp">cls&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">info&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">version&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Enforcing contract path structure at import time makes the contract classes impossible to define anywhere else, which is a problem the first time a scientist wants to draft a schema in a notebook to see what it looks like. Doing it at export means the rule is enforced on everything that gets published, while drafting stays cheap. There&amp;rsquo;s a context manager for the notebook usecase too:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">relaxed_registry_checks&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nd">@register_dataframe_schema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="o">...&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">class&lt;/span> &lt;span class="nc">DraftSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">ContractMixin&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">BaseDataFrameSchema&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>But there is a tradeoff, such a badly-placed schema fails later than it could. In practice &amp;ldquo;later&amp;rdquo; is the export step, which runs in CI on every PR, so nothing badly-placed reaches S3 regardless. Contracts inherit from a base model that sets the validation posture in one place:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">BaseDataFrameSchema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">pa&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DataFrameModel&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">class&lt;/span> &lt;span class="nc">Config&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">strict&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;filter&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">coerce&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>strict='filter'&lt;/code> drops columns not in the schema rather than raising, which for source-system data is almost always what you want. The LIMS base adds &lt;code>drop_invalid_rows = True&lt;/code> and the row identity columns every LIMS table carries.&lt;/p>
&lt;hr>
&lt;h2 id="versioning-semantic-path-encoded-and-strict">Versioning: semantic, path-encoded, and strict&lt;/h2>
&lt;p>Schema versions follow semantic versioning (&lt;code>1.0&lt;/code>, &lt;code>2.1&lt;/code>, &lt;code>1.0.1&lt;/code>) and are encoded into the directory structure as Python-safe package names:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">v1_0 → 1.0
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v2_1 → 2.1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">v1_0_1 → 1.0.1&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The conversion is bidirectional and validated in both directions. When I need to add a new version of a schema, I create a new directory alongside the old one. Both continue to exist and be served by the registry.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">cell_count/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v1_0/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml ← auto-generated
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> v2_0/ ← new version; v1_0 still works
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Version resolution supports three modes: a specific version (&lt;code>&amp;quot;1.0&amp;quot;&lt;/code>), &lt;code>&amp;quot;latest&amp;quot;&lt;/code> (resolved by semantic comparison at read time), and &lt;code>None&lt;/code> (all versions). This last mode is useful when you need to understand the full version history of a schema. Resolving &lt;code>latest&lt;/code> uses &lt;code>packaging.version.Version&lt;/code> for comparison rather than string sorting, so &lt;code>10.0&lt;/code> sorts above &lt;code>9.0&lt;/code>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">strver&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">vers&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">semver&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Version&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">strver&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># don&amp;#39;t allow pre-releases, or dev-releases&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pre&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="n">semver&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dev&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parsed&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">semver&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">strver&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So I can publish &lt;code>2.0.0rc1&lt;/code>, point a pipeline at it explicitly to test, and know that nothing pinned to &lt;code>latest&lt;/code> will pick it up by accident.&lt;/p>
&lt;hr>
&lt;h2 id="export-pipeline-python--yaml">Export pipeline: python → YAML&lt;/h2>
&lt;p>Rather than maintaining YAML files by hand, the build step automatically discovers and converts decorated Pandera models. Discovery works by walking the file tree looking for &lt;code>schema.py&lt;/code> files, dynamically importing them as namespace packages (which avoid collisions between modules at the same relative path), and inspecting each imported module for classes that subclass both &lt;code>pa.DataFrameModel&lt;/code> and &lt;code>ContractMixin&lt;/code> and have a populated &lt;code>__schema_info__&lt;/code>. The code builds a unique dotted module name from the full path:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">_path_to_module_name&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">filepath&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">package_root&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rel&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">filepath&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">relative_to&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">package_root&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">resolve&lt;/span>&lt;span class="p">())&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">parts&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">rel&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">with_suffix&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parts&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># → &amp;#34;contracts.domains.lab.analytics.cell_count.v1_0.schema&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Each import gets a unique name in &lt;code>sys.modules&lt;/code> to prevent collisions (ideally we wouldn&amp;rsquo;t be naming schemas the same name, but this approach allows for it). Setting &lt;code>mod.__package__&lt;/code> to the parent of that dotted name keeps relative imports inside the schema file working.&lt;/p>
&lt;p>Pandera fields are serialized into a YAML-friendly dictionary, and checks that can be represented cleanly (e.g. comparisons and membership tests) are serialized. Anything else is skipped with a warning, since they&amp;rsquo;re harder to represent. Not ideal if you &lt;em>do&lt;/em> want some more complicated validation logic, but works for now &amp;ndash; though I think Great Expectations is probably better for these sorts of checks than Pandera is.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># pa.Check.greater_than(0) → {&amp;#34;gt&amp;#34;: 0}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># pa.Check.isin([&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]) → {&amp;#34;isin&amp;#34;: [&amp;#34;A&amp;#34;, &amp;#34;B&amp;#34;]}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># lambda df: df[&amp;#34;x&amp;#34;] &amp;gt; df[&amp;#34;y&amp;#34;] → warning, skipped&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The serializer returns a &lt;em>list&lt;/em> per check rather than a single dict, because some Pandera checks are compound. &lt;code>in_range&lt;/code> has to expand into a &lt;code>ge&lt;/code> entry and an &lt;code>le&lt;/code> entry, since the YAML vocabulary has no range primitive. The resulting YAML is human-readable and versionable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">schema&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab.analytics.cell_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">domain&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">lab&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">entity&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">cell_count&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">owner&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">data-platform&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">schema_type&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">dataframe&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">description&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS cell count assay table.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">meta&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">tier&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bronze&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">alignment&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">source&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">source_system&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">LIMS&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">columns&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">run_row_id&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">str&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nullable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">false&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">coerce&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">viability&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">dtype&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">float64&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">nullable&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">coerce&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="kc">true&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">checks&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">ge&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">0.0&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">le&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">1.0&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="canonical-dtype-vocabulary">Canonical dtype vocabulary&lt;/h2>
&lt;p>My initial version of a Python-to-YAML-to-Python round trip wrote out whatever dtype spelling the contract author (a.k.a. me) used. Pandera and pandas and &lt;code>typing&lt;/code> will happily provide &lt;code>float&lt;/code>, &lt;code>float64&lt;/code>, &lt;code>double&lt;/code>, &lt;code>np.float64&lt;/code>, &lt;code>typing.List[float]&lt;/code>, and &lt;code>Timestamp&lt;/code> for what are actually three types. I didn&amp;rsquo;t want consuming services to have to deal with this ambiguity. The registry now defines a closed dtype vocabulary:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">scalars: str, int32, int64, float32, float64, bool, datetime64[ns]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">lists: List[&amp;lt;scalar&amp;gt;] (element one of int/float/str/bool)&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Author-side spellings are normalized to that vocabulary in exactly one function, &lt;code>canonicalize_dtype&lt;/code>, called at export time:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">_SCALAR_ALIASES&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Dict&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;int&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;integer&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;int64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;float&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;double&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;float64&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;string&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;str&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;boolean&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;bool&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;datetime&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;timestamp&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;date&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;datetime64[ns]&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># ... plus the canonical spellings mapping to themselves&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="publishing-contracts">Publishing contracts&lt;/h2>
&lt;p>The dtype vocabulary is only a contract if something enforces it, so publishing to S3 runs every document through a hard gate first:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">assert_canonical_dtypes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;&amp;lt;doc&amp;gt;&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">schema&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">{})&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;schema_type&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">!=&lt;/span> &lt;span class="s2">&amp;#34;dataframe&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">col&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">doc&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;columns&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="ow">or&lt;/span> &lt;span class="p">[]):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">dtype&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;dtype&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">dtype&lt;/span> &lt;span class="ow">is&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">canonical&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">canonicalize_dtype&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">except&lt;/span> &lt;span class="n">UnknownDtypeError&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">e&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">e&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">is_canonical&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">problems&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">append&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;column &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">col&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">: dtype &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">dtype&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2"> is not canonical &amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;(expected &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">canonical&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">), re-export this schema.&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">problems&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">raise&lt;/span> &lt;span class="ne">ValueError&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sa">f&lt;/span>&lt;span class="s2">&amp;#34;Refusing to publish &lt;/span>&lt;span class="si">{&lt;/span>&lt;span class="n">schema&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;name&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="si">!r}&lt;/span>&lt;span class="s2">:&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s2"> - &amp;#34;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">problems&lt;/span>&lt;span class="p">))&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The two rejection cases are doing different jobs. Rejecting an unknown dtype catches a type consumers can&amp;rsquo;t deserialize. Rejecting a dtype that is merely &lt;em>non-canonical&lt;/em> (one that &lt;code>canonicalize_dtype&lt;/code> could fix) catches something else entirely: it means this YAML was not produced by the current exporter. Someone hand-edited it, or it was generated before the normalization step existed. Silently canonicalizing it at publish time would hide that. Refusing forces a re-export, which keeps the YAML in S3 in sync with the Python that claims to define it. The check acts as sort of a staleness detector.&lt;/p>
&lt;hr>
&lt;h2 id="schema-templates-validating-the-schemas-themselves">Schema templates: validating the schemas themselves&lt;/h2>
&lt;p>Separately from dtypes, each schema document is validated against a JSON Schema template selected by its &lt;code>schema_type&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">contracts/templates/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> dataframe/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> event/v1_0/schema.yaml
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> api/v1_0/schema.yaml&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>At publish time the templates are themselves checked as valid JSON Schemas with &lt;code>Draft202012Validator.check_schema&lt;/code>, compiled once, and then each contract is validated against the template matching its declared type. A schema with no &lt;code>schema_type&lt;/code>, or one naming a type with no template, is a hard failure, and wont upload.&lt;/p>
&lt;p>This is the layer that lets the registry hold more than DataFrames. &lt;code>dataframe&lt;/code> contracts carry &lt;code>columns&lt;/code>. &lt;code>api&lt;/code> contracts carry &lt;code>request&lt;/code>, &lt;code>response&lt;/code>, and &lt;code>callback&lt;/code> sections. &lt;code>event&lt;/code> contracts carry their own shape. They all live in the same tree, share the same identity and versioning rules, and are all published through the same gate, but each is structurally validated against its own template.&lt;/p>
&lt;hr>
&lt;h2 id="schema-metadata-queryable-semantic-tags">Schema metadata: queryable semantic tags&lt;/h2>
&lt;p>Every schema can carry structured metadata in a &lt;code>SchemaMeta&lt;/code> dataclass:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="nd">@dataclass&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">frozen&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">SchemaMeta&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">tier&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;silver&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;gold&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">alignment&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;source&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;canonical&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;denormalized&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">contract_type&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Literal&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;ingress&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;internal&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;egress&amp;#34;&lt;/span>&lt;span class="p">]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">source_system&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">Union&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">List&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]]]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">stage&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">Optional&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">None&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These fields encode the data&amp;rsquo;s position in a medallion-style architecture. Bronze/silver/gold indicate the refinement level. Alignment captures whether data is still in the shape of its source system, has been canonicalized, or has been denormalized for consumption. Contract type captures directionality in the platform: ingress for uploads and instrument files, internal for the middle of pipelines, egress for published dashboards and APIs. This metadata is queryable:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">registry&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Registry&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">s3_bucket&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;my-data-lakehouse&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find all bronze schemas sourced from the LIMS&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">lims_bronze&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tier&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;bronze&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">source_system&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;lims&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Find all schemas used as egress contracts&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">egress_schemas&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">find_by_meta&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">contract_type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;egress&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Get all tiers currently in use&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">registry&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_tiers&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="c1"># → [&amp;#34;bronze&amp;#34;, &amp;#34;gold&amp;#34;, &amp;#34;silver&amp;#34;]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This became invaluable for impact analysis. When a source system changed its output, I could immediately identify which schemas were &lt;code>alignment=&amp;quot;source&amp;quot;&lt;/code> and &lt;code>source_system=&amp;quot;lims&amp;quot;&lt;/code> and therefore potentially affected.&lt;/p>
&lt;p>The same identity travels with the data, not just the registry. &lt;code>ContractMixin&lt;/code> can flatten a contract into a string key/value header for stamping onto parquet metadata:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.domain&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;lab&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.entity&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;contract.version&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="o">...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>So a file on disk can answer which contract and which version produced it, without anyone having to consult a pipeline log.&lt;/p>
&lt;hr>
&lt;h2 id="runtime-registry">Runtime registry&lt;/h2>
&lt;p>The &lt;code>Registry&lt;/code> class wraps an S3 backend and provides the consumer-facing API. It&amp;rsquo;s thin by design. Most logic lives in the export pipeline and the contract definitions themselves.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">schema_registry_client&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">REGISTRY&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Load a schema as a live Pandera model&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">Model&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_pandera_model&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;latest&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Validate a DataFrame against it&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">validated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Model&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">validate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">df&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Or get the raw YAML doc&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">doc&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">REGISTRY&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_schema&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;lab.analytics.cell_count&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;1.0&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>get_pandera_model&lt;/code> builds a class dynamically: each YAML column becomes a Pandera &lt;code>Field&lt;/code> plus a type annotation, the check dicts become &lt;code>Field&lt;/code> kwargs, and the schema&amp;rsquo;s config overrides are applied on top of the base model&amp;rsquo;s config. Because the dtype strings are guaranteed canonical, the annotation lookup is a single flat dict with seven entries and no fallbacks.&lt;/p>
&lt;p>The backend reads an index CSV written at upload time rather than doing live &lt;code>list_objects&lt;/code> calls:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">contracts/index/schema_index.csv # name, key, version, date, schema_type
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">integrations/index/spec_index.csv&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Listing names, resolving versions, and enumerating domains are all filters over that dataframe. It loads once and stays cached on the backend instance. This keeps latency predictable and avoids a per-request S3 API call every time something enumerates the registry. The cost is that the index is a build artifact like everything else: it&amp;rsquo;s rewritten on every publish, so it&amp;rsquo;s accurate as of the last upload and no fresher.&lt;/p>
&lt;p>Alongside the contracts, the registry stores integration specs that map a contract to its physical source table: schema and table name, a &lt;code>field_to_source&lt;/code> column mapping, and the created/modified columns used for date partitioning. That&amp;rsquo;s what lets a pipeline say &amp;ldquo;load this contract for this date&amp;rdquo; without knowing anything about the source system&amp;rsquo;s table layout. It&amp;rsquo;s also the seam where a source system renaming a column becomes a spec change rather than a pipeline change.&lt;/p>
&lt;hr>
&lt;h2 id="api">API&lt;/h2>
&lt;p>The registry is also served over HTTP by a FastAPI app deployed as an AWS Lambda function, with Cognito authentication applied as a global dependency and a small set of public paths for health and docs.&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>Method&lt;/th>
&lt;th>Path&lt;/th>
&lt;th>Description&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/domains/&lt;/code>&lt;/td>
&lt;td>List all domains&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/&lt;/code>&lt;/td>
&lt;td>List schema names (&lt;code>?domain=&lt;/code>, &lt;code>?version=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}&lt;/code>&lt;/td>
&lt;td>A schema document (&lt;code>?version=latest&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/versions&lt;/code>&lt;/td>
&lt;td>List versions for a schema&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>GET&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/diff&lt;/code>&lt;/td>
&lt;td>Diff two versions (&lt;code>?from=&amp;amp;to=&lt;/code>)&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;code>POST&lt;/code>&lt;/td>
&lt;td>&lt;code>/schemas/{name}/validate&lt;/code>&lt;/td>
&lt;td>Validate a payload against a schema section&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>The &lt;code>diff&lt;/code> endpoint answers &amp;ldquo;what actually changed between 1.0 and 2.0&amp;rdquo;, which is helpful if something starts breaking after publishing schemas. The &lt;code>validate&lt;/code> endpoint lets a service check a payload against an &lt;code>api&lt;/code>-type contract over HTTP, without installing the client or writing Python at all. It refuses if you point it at a &lt;code>dataframe&lt;/code> schema, since there&amp;rsquo;s no meaningful section to validate against.&lt;/p>
&lt;p>Mostly, though, the API exists so that the registry is browsable by people who aren&amp;rsquo;t going to &lt;code>pip install&lt;/code> anything. Being able to send a scientist a URL that shows exactly what a table contains has done more for adoption than any amount of documentation. I ended up building a nice dashboard that displayed datatable-specific contracts as tables, and let&amp;rsquo;s users browse by domain, subdomain(s), entity, and version. It&amp;rsquo;s much easier to look at than a YAML file or Pandera class.&lt;/p>
&lt;hr>
&lt;h2 id="closing-toughts">Closing toughts&lt;/h2>
&lt;p>A schema registry is about making implicit contracts explicit. Before building this, the &amp;ldquo;contract&amp;rdquo; between a pipeline and its consumer (e.g. dashboards, APIs) was whatever the pipeline happened to output on any given run. Afterwards, it was a versioned, machine-readable document that both sides could validate against independently. None of this is domain-specific, and these same patterns would work for any environment where you need versioned, queryable, code-first schema definitions that can be serialized and distributed. I&amp;rsquo;ve incorporated contracts for everything from scientific instruments, to finance, to ERP domains in our registry.&lt;/p>
&lt;p>I underestimated how much work the &lt;em>enforcement&lt;/em> would be, as opposed to the contract definitions themselves. Defining schemas as Python classes is easy, and scientists who are somewhat Python-saavy can do this easily as well. Making it impossible to publish a schema that consumers can&amp;rsquo;t read, or to end up with two spellings of the same type, or to quietly delete something a pipeline still depends on, took a lot longer, and is what makes the system robust. For our small team in a domain with complex, heterogeneous data, implementing this registry and schema enforcement has been a huge boon.&lt;/p>
&lt;hr>
&lt;p>&lt;em>I work on data platform infrastructure at a biologics company. The patterns in this post are generalized from production code, with domain-specific details abstracted away. The core framework concepts are shareable.&lt;/em>&lt;/p></description></item><item><title>CI/CD Part 2: Docker Images and Container Registries</title><link>https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/</link><pubDate>Tue, 30 May 2023 02:14:14 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/</guid><description>&lt;p>This is the second of two posts on designing Gitlab CI/CD pipelines. The
&lt;a href="https://kristianeschenburg.netlify.app/post/cicd-1-pipelines-and-packages/">first&lt;/a> covered the anatomy of a &lt;code>.gitlab-ci.yml&lt;/code> file, conditional jobs, and setting up the &lt;code>.pypirc&lt;/code> and &lt;code>.netrc&lt;/code> files that let a pipeline build a Python package and push it to a registry.&lt;/p>
&lt;p>This post covers what happens after that: writing a Dockerfile to build an image from that package, and pushing the image to a container registry, both the Gitlab one and AWS ECR.&lt;/p>
&lt;h2 id="multi-stage-docker-builds">Multi-stage Docker builds&lt;/h2>
&lt;p>I&amp;rsquo;m using what&amp;rsquo;s called a &amp;ldquo;multi-stage&amp;rdquo; build. This is exactly what it sounds like &amp;ndash; it breaks the process of building a Docker image into multiple stages. In doing so, we often have the benefit of a final image that is smaller than a single-stage build, because we only include the artifacts needed to run our containerized application.&lt;/p>
&lt;p>Similarly, we can leverage multi-stage Docker builds to minimize duplicated code in Dockerfiles. For example, let&amp;rsquo;s say we have a scenario where we want to build an image for a &lt;code>Production&lt;/code> environment as well as a &lt;code>Test&lt;/code> environment. The &lt;code>Test&lt;/code> environment might include some additional dependencies, scripts, exports, etc. that the Production environment doesn&amp;rsquo;t. Instead of creating two Dockerfiles, one for each environment, we can define a single stage that encompasses the overlapping parts of both the &lt;code>Production&lt;/code> and &lt;code>Test&lt;/code> images, and then define the extra stuff in a separate stage to build the &lt;code>Test&lt;/code> image.&lt;/p>
&lt;p>In the example below, we have a three-stage Docker build, with stage names:&lt;/p>
&lt;ul>
&lt;li>&lt;code>base&lt;/code>: sets up some basic environment variables&lt;/li>
&lt;li>&lt;code>python-deps&lt;/code>: installs your package and creates a virtual environment&lt;/li>
&lt;li>&lt;code>runtime&lt;/code>: the actual application you want to run, with only the necessary files for running it&lt;/li>
&lt;/ul>
&lt;h3 id="create-a-base-image">Create a base image&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># set base image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># bigger base images yield slower image load times, and have more security vulnerabilities&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">FROM python:3.9-slim as base&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># install virtual environment in ${project_dir}/.venv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ENV PIPENV_VENV_IN_PROJECT 1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># dont write .pyc files&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ENV PYTHONDONTWRITEBYTECODE 1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># get some more information about faults when building images&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ENV PYTHONFAULTHANDLER 1&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="install-package-dependencies">Install package dependencies&lt;/h3>
&lt;p>If you were building your Docker image locally, you&amp;rsquo;d have access to any authentication tokens or SSH keys necessary to pull from remote or private repositories. However, Docker is naive to these variables &amp;ndash; we have to explicitly provide them at build time. Within the Dockerfile, we define three environment variables using the &lt;code>ARG&lt;/code> keyword:&lt;/p>
&lt;ol>
&lt;li>&lt;code>CI_DEPLOY_USER&lt;/code>&lt;/li>
&lt;li>&lt;code>CI_DEPLOY_PASSWORD&lt;/code>&lt;/li>
&lt;li>&lt;code>CI_JOB_TOKEN&lt;/code>&lt;/li>
&lt;/ol>
&lt;p>If you think that these variables look familiar, you&amp;rsquo;re right. They&amp;rsquo;re the same pre-defined variables that exist in the context of a Gitlab CI/CD pipeline that act as authentication tokens for a &lt;code>.pypirc&lt;/code> file and &lt;code>.netrc&lt;/code> &amp;ndash; they&amp;rsquo;re utilized by the &lt;code>setup_tokens.sh&lt;/code> script to set up the &lt;code>.pypirc&lt;/code> and &lt;code>.netrc&lt;/code> files &lt;em>within&lt;/em> the Docker image.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># image for installing dependencies&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># we only need .venv and app `runtime` image, not all other bloat&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">FROM base AS python-deps&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c">####################################&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># ----------------------------------&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ARG CI_DEPLOY_USER&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ARG CI_DEPLOY_PASSWORD&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ARG CI_JOB_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># ----------------------------------&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c">####################################&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>These variables are available outside of the Docker image, but not within the image itself, so we need to &amp;ldquo;show&amp;rdquo; them to Docker at build time via the &lt;code>--build-arg&lt;/code> flag:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker build --build-arg &lt;span class="nv">CI_DEPLOY_USER&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$CI_DEPLOY_URDER&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --build-arg &lt;span class="nv">CI_DEPLOY_PASSWORD&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$CI_DEPLOY_PASSWORD&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> --build-arg &lt;span class="nv">CI_JOB_TOKEN&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$CI_JOB_TOKEN&lt;/span> &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> ...
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ...&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now they are contained within the Docker image and can be provided to the &lt;code>setup_tokens.sh&lt;/code> script, which then allows us to pull packages down from our remote package registry. We also no longer need the SSH keys, since we&amp;rsquo;re authenticating through Gitlab itself.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># install pipenv in `python-deps` image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN python3 -m pip install pipenv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN apt-get update \&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="cp">&amp;amp;&amp;amp;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">apt-get install --yes --no-install-recommends gcc g++ libffi-dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Dependency installation looks a little different for local packages&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">WORKDIR /home/app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># copy files to `python-deps` image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">COPY setup.py setup_tokens.sh Pipfile Pipfile.lock ./&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># copy over application-specific code that you want to install&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># this is unique to my specific project -- use your own directories here&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">COPY templateci/ templateci/&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># run setup_tokens script to setup .pypirc and .netrc within image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN chmod +x ./setup_tokens.sh &amp;amp;&amp;amp; ./setup_tokens.sh&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># authentication tokens are now available to pipenv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN python3 -m pipenv install --deploy --dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># get rid of unnecessary libraries after install&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN apt-get autoremove --yes gcc g++ libffi-dev \&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="cp">&amp;amp;&amp;amp;&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">rm -rf /var/lib/apt/lists/*&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="create-your-final-runtime-image">Create your final runtime image&lt;/h3>
&lt;p>Above, we created the virtual environment that allows our application to run. As such, we no longer need the raw source code or any other random files that were contained in the original project directory that might have been needed to build the virtual environment. Now we create a stage called &lt;code>runtime&lt;/code> in which we copy over the generated virtual environment from the previous stage&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># image for running the application&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">FROM base AS runtime&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Copy virtual environment from `python-deps` image to `runtime` image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">COPY --from=python-deps /home/app/.venv /.venv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># add virtual environment to PATH&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">ENV PATH=&amp;#34;/.venv/bin:$PATH&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Create new user -- app will run as new user&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">RUN useradd --create-home -u 1099 user&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">WORKDIR /home/user/app&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">USER user&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">COPY . .&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="l">CMD [&amp;#34;python3&amp;#34;, &amp;#34;-m&amp;#34;, &amp;#34;pytest&amp;#34;]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This is the actual image that gets run when we call&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker run &lt;span class="si">${&lt;/span>&lt;span class="nv">IMAGE_NAME&lt;/span>&lt;span class="si">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="pushing-to-aws-ecr">Pushing to AWS ECR&lt;/h2>
&lt;p>The
&lt;a href="https://kristianeschenburg.netlify.app/post/cicd-1-pipelines-and-packages/#conditional-pipeline-jobs">conditional job&lt;/a> at the end of the first post pushed an image to the Gitlab Container Registry. Pushing to a remote AWS Elastic Container Registry (ECR) instead follows the same structure, with a different base image and a different authentication step.&lt;/p>
&lt;h3 id="setting-up-aws-variables">Setting up AWS variables&lt;/h3>
&lt;p>To build images, tag them, and push them to the remote AWS ECR, I used the definition of a CI/CD job below. In addition to pre-defined variables that are set internally by Gitlab, we can also manually pre-define variables. In this case, I&amp;rsquo;ve set a few that allow me to interact with AWS via the command line:&lt;/p>
&lt;ul>
&lt;li>&lt;code>AWS_DEFAULT_REGION&lt;/code>: self-explanatory&lt;/li>
&lt;li>&lt;code>ECR_REPO_LAMBDA&lt;/code>: &lt;code>${AWS_ACCOUNT_ID}&lt;/code>.dkr.ecr.&lt;code>${AWS_DEFAULT_REGION}&lt;/code>.amazonaws.com/&lt;code>${YOUR_ECR_REPO_NAME}&lt;/code>&lt;/li>
&lt;li>&lt;code>AWS_ACCOUNT_ID&lt;/code>: AWS account ID&lt;/li>
&lt;li>&lt;code>AWS_ACCESS_KEY&lt;/code>: this is the information contained in the downloaded *.pem file&lt;/li>
&lt;li>&lt;code>AWS_SECRET_ACCESS_KEY&lt;/code>: this is the information contained in the downloaded *.pem file&lt;/li>
&lt;/ul>
&lt;p>To set variables that are accessible by CI/CD jobs, go to your &lt;strong>Project/Group &amp;gt; Settings &amp;gt; CI/CD &amp;gt; Variables &amp;gt; Expand&lt;/strong> and define the variables of interest:&lt;/p>
&lt;p>&lt;img src="https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/cicd-variable-tab.png" alt="">&lt;/p>
&lt;p>&lt;img src="https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/cicd-variables.png" alt="">&lt;/p>
&lt;p>If you define these variables at the Gitlab Group level, they will be propagated down to the project level, so long as the Project falls under the Group scope.&lt;/p>
&lt;h3 id="pushing-to-aws-ecr-via-cicd-job">Pushing to AWS ECR via CI/CD job&lt;/h3>
&lt;p>Below, we define the actual CI/CD job. There were two aspects here that I needed to solve. First, I needed access to a Docker-in-Docker build image e.g. an image that had Docker installed. And second, this image also needed to have the AWS CLI tool installed. To that end, I used the &lt;code>bentolor/docker-dind-awscli&lt;/code>
&lt;a href="https://github.com/bentolor/docker-dind-awscli" target="_blank" rel="noopener">image&lt;/a>.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">build-image-ecr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">deploy &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">bentolor/docker-dind-awscli&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker:dind&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># convenience variable indicating name of the image with respect to the ECR repo and unique tag ID&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">IMAGE_TAG&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$ECR_REPO_LAMBDA:$CI_COMMIT_SHORT_SHA&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">before_script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker info&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># authenticate docker with your AWS ECR account&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">aws ecr get-login-password --region $AWS_DEFAULT_REGION | docker login --username AWS --password-stdin $AWS_ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># will push Docker image to AWS ECR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># build the docker imagae&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker build --compress -t ${IMAGE_TAG} .&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># tag the image with a unique name&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker tag ${IMAGE_TAG} $ECR_REPO_LAMBDA:latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># push the image to the ECR&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker push ${IMAGE_TAG}&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># here, we only build and push the image if this is a merge event into the &amp;#34;main&amp;#34; branch&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$CI_PIPELINE_SOURCE == &amp;#39;merge_request_event&amp;#39; &amp;amp;&amp;amp; $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == &amp;#34;main&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila! You have now pushed your built image to a remote container registry!&lt;/p></description></item><item><title>CI/CD Part 1: Gitlab Pipelines and Package Registries</title><link>https://kristianeschenburg.netlify.app/post/cicd-1-pipelines-and-packages/</link><pubDate>Tue, 02 May 2023 02:14:14 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/cicd-1-pipelines-and-packages/</guid><description>&lt;p>I recently developed a template workflow to help our team adopt a CI/CD-based development strategy. Many of our web applications and tools were based on simple repository structures. With growing datasets and ever-increasing use by outside teams, we found ourselves needing to add new features more frequently to many of these tools and believed that continuous integration and deployment could help us not just develop more quickly, but also more intelligently. Since we use Gitlab to store our code, we decided to use the Gitlab CI/CD tools.&lt;/p>
&lt;p>Documentation on much of this process was scattered and/or sparse, so I decided to put what I learned and implemented into a more coherent set of notes. This post covers the anatomy of a &lt;code>.gitlab-ci.yml&lt;/code> file, how jobs and stages fit together, how to make a job conditional, and how to authenticate against a package registry so a pipeline can build a Python package and push it there.&lt;/p>
&lt;p>The
&lt;a href="https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/">second post&lt;/a> covers the other half: writing a multi-stage Dockerfile to build an image from that package, and pushing the image to a container registry.&lt;/p>
&lt;h2 id="setup">Setup&lt;/h2>
&lt;p>I did all of my testing using my personal Gitlab account. To separate things out, I created a new Project called &amp;ldquo;Package Registry”, as well as a test repository that was used for building a local Python project called &amp;ldquo;TemplateCI&amp;rdquo;.&lt;/p>
&lt;p>The &amp;ldquo;Package Registry&amp;rdquo; Project serves as just that &amp;ndash; an all-inclusive location for any software packages your CI/CD pipelines build. You can find the built packages by clicking &lt;strong>${Project Name} &amp;gt; Deploy &amp;gt; Package Registry&lt;/strong>. Every &amp;ldquo;Project&amp;rdquo; in Gitlab has the ability to store packages in its own registry, but I felt it cleaner to store everything in one repo. Similarly, the actual code that I&amp;rsquo;ll be packaging will be stored in the &amp;ldquo;TemplateCI&amp;rdquo; repo.&lt;/p>
&lt;h2 id="authentication-pypirc-and-netrc">Authentication: .pypirc and .netrc&lt;/h2>
&lt;p>In order to build packages and push them to a remote package registry, we use the &lt;code>build&lt;/code> and &lt;code>twine&lt;/code> packages. &lt;code>build&lt;/code> generates a package, and &lt;code>twine&lt;/code> pushes this package to a registry (or &amp;ldquo;index&amp;rdquo;). &lt;code>twine&lt;/code> requires access to authentication usernames, passwords, and a registry URL in order to do so. &lt;code>twine&lt;/code> can access these tokens from a &lt;code>.pypirc&lt;/code> file &amp;ndash; the tokens are generated by the registry, and ensure that the submitting user has permissions to perform a certain action.&lt;/p>
&lt;p>Other processes, such as pulling or pushing code from a remote repository, often require additional usernames and passwords. In order to alleviate the need to consistently provide these variables at request time, we can save them in a &lt;code>.netrc&lt;/code> file.&lt;/p>
&lt;p>These are straightforward to set up locally. But we also need to set these up to ensure a properly functional CI/CD workflow. I&amp;rsquo;ve put together a basic script, called &lt;code>setup_tokens.sh&lt;/code> that does just that:&lt;/p>
&lt;h3 id="setup_tokenssh">setup_tokens.sh&lt;/h3>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># generate .pypirc file&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;[distutils]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">index-servers =
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> personal
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">[personal]
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">repository = https://gitlab.com/api/v4/projects/&lt;/span>&lt;span class="nv">$PACKAGE_REGISTRY_ID&lt;/span>&lt;span class="s2">/packages/pypi
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">username = &lt;/span>&lt;span class="nv">$CI_DEPLOY_USER&lt;/span>&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">password = &lt;/span>&lt;span class="nv">$CI_DEPLOY_PASSWORD&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &amp;gt; ~/.pypirc
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># generate .netrc file&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nb">echo&lt;/span> &lt;span class="s2">&amp;#34;machine gitlab.com
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">login gitlab-ci-token
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">password &lt;/span>&lt;span class="nv">$CI_JOB_TOKEN&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> &amp;gt; ~/.netrc&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The &lt;code>.pypirc&lt;/code> refers to your Project Registry via a previously generated authentication token and password, and allows your to build and upload Python packages to that registry.&lt;/p>
&lt;p>The &lt;code>.netrc&lt;/code> file enables you to pull private packages from that same registry. In the context of our work, we&amp;rsquo;ll want to build and push packages to the registry first so that they are available for pulling. For example, in the &lt;code>Pipfile&lt;/code> for this template project, we have the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-toml" data-lang="toml">&lt;span class="line">&lt;span class="cl">&lt;span class="p">[[&lt;/span>&lt;span class="nx">source&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">url&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;https://pypi.org/simple&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">verify_ssl&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">name&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;pypi&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">[[&lt;/span>&lt;span class="nx">source&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">url&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;https://${CI_DEPLOY_USER}:${CI_DEPLOY_PASSWORD}@gitlab.com/api/v4/projects/${$PACKAGE_REGISTRY_ID}/packages/pypi/simple&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">verify_ssl&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="kc">true&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">name&lt;/span> &lt;span class="p">=&lt;/span> &lt;span class="s2">&amp;#34;personal&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>We see the same user authentication happening, along with the reference to the Package Registry ID variable. For local installation of your package, and in order to make sure that your &lt;code>Pipfile&lt;/code> and &lt;code>Pipfile.lock&lt;/code> are in sync, you&amp;rsquo;ll need to define the following local environment variables:&lt;/p>
&lt;ul>
&lt;li>&lt;code>CI_DEPLOY_USER&lt;/code>: generated user token&lt;/li>
&lt;li>&lt;code>CI_DEPLOY_PASSWORD&lt;/code>: generated token password&lt;/li>
&lt;li>&lt;code>PACKAGE_REGISTRY_ID&lt;/code>: the ID of the repository that you created that will store your packages&lt;/li>
&lt;/ul>
&lt;h2 id="basic-jobs">Basic jobs&lt;/h2>
&lt;p>The basis of a Gitlab CI/CD pipeline is the &lt;code>.gitlab-ci.yml&lt;/code> file, which is composed of a set of explicitly-defined and temporally-ordered “stages”. A stage is composed of a set of “jobs”. Jobs are the workhorses of the CI/CD pipeline, and define explicit tasks that a CI/CD pipeline runs. By default, all jobs from one stage run in parallel, unless specified otherwise (using the &lt;code>needs&lt;/code> keyword as an attribute of a job induces a temporal directed acyclic graph – jobs can be made “dependent” on the successful completion of other jobs within a stage). In this example, I’ve defined three stages:&lt;/p>
&lt;ol>
&lt;li>run-unit-tests&lt;/li>
&lt;li>build-package&lt;/li>
&lt;li>build-image&lt;/li>
&lt;/ol>
&lt;p>Any job associated with the &lt;code>run-unit-tests&lt;/code> stage will run to completion (or failure) PRIOR TO ANY job in the stages &lt;code>build-package&lt;/code> and &lt;code>build-image&lt;/code> starting. If all jobs in the &lt;code>run-unit-tests&lt;/code> stage complete successfully, then the next stage (&lt;code>build-package&lt;/code>) will begin. We define individual jobs, and give them a stage attribute. Stages define the rough ordering of jobs. Each job runs in the context of an “image” or environment. We can set a global &lt;code>image&lt;/code> and define stages as&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># global CI/CD image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">python:3.9-slim&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># stages of this example pipeline&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stages&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">run-unit-tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">build-package&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">build-image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">LC_ALL&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">C.UTF-8&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">LANG&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">C.UTF-8&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>or define the image as an attribute of a job. Gitlab CI/CD by default uses Docker images in which to run jobs. Setting the image is analogous to using the &lt;code>FROM&lt;/code> command in a Dockerfile. We&amp;rsquo;ve also set some global variables, here the &lt;code>LC_ALL&lt;/code> and &lt;code>LANG&lt;/code> variables.&lt;/p>
&lt;p>To “run” Gitlab pipelines for the purpose of CI/CD, we use “runners”, which are build instances installed on a server. Gitlab offers “shared” runners (use of these is free if you use
&lt;a href="https://www.gitlab.com" target="_blank" rel="noopener">www.gitlab.com&lt;/a>, but you need to register a credit card to prevent abuse of Gitlab resources). You can also register your own device(s) to act as a Gitlab runner.&lt;/p>
&lt;p>Below are examples of two jobs in the &lt;code>run-unit-tests&lt;/code> stage. These two jobs are effectively the same code, apart from the unique unit tests that they run. However, we&amp;rsquo;ve made the job &lt;code>unit-tests-2&lt;/code> dependent on the output of the job &lt;code>unit-tests-1&lt;/code> (see the &lt;code>needs&lt;/code> keyword of &lt;code>unit-tests-2&lt;/code>). Both jobs use the global &lt;code>python:3.9-slim&lt;/code> image. We can run some “setup” stuff (&lt;code>before_script&lt;/code>), run an actual script (&lt;code>script&lt;/code>), and run clean up (&lt;code>after_script&lt;/code>, not shown) &amp;ndash; these delineations (before, during, after) are for organizational purposes, and not due to any explicit functional differences in the delineations.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># setup_tokens.sh is the script from the previous section.&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># example job #1&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">unit-tests-1&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">run-unit-tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">before_script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">chmod +x ./setup_tokens.sh; ./setup_tokens.sh&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pip install pipenv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get update&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get install --yes --no-install-recommends gcc g++ libffi-dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pipenv install --deploy --dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># run first set of unit tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pipenv run pytest -k &amp;#39;test_examples1.py&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># example job #2&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">unit-tests-2&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">run-unit-tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">before_script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">chmod +x ./setup_tokens.sh; ./setup_tokens.sh&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pip install pipenv&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get update&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get install --yes --no-install-recommends gcc g++ libffi-dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pipenv install --deploy --dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># run second set of unit tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pipenv run pytest -k &amp;#39;test_examples2.py&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># wait for job 1 to finish&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">needs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">unit-tests-1]&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="conditional-pipeline-jobs">Conditional pipeline jobs&lt;/h2>
&lt;p>The above jobs are relatively simple and will run every time you push a repository to Gitlab. However, sometimes, we might only want to run a job if certain conditions are met. For example, we might only want to build a package from the &lt;code>main&lt;/code> branch, or only after a merge request is made. To this end, we can add “rules” to a job that restrict when it is actually run.&lt;/p>
&lt;p>Below is a more complicated job example. The overarching goal of this job is to build a Docker image from a local Python project and push the image to the Gitlab Container Registry. There’s a lot going on here, so I’ll break it up into pieces, but here is the whole job:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c"># Conditional job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Building a docker image and pushing this container to a container registry&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Link to main image: https://github.com/bentolor/docker-dind-awscli&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># Conditions:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># --- merge request events&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># --- target branch of merge request is &amp;#34;main&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c"># job name&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">build-image-glcr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># stage of pipeline&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">build-image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># image that job is based on&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker:20.10.16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># sub-services of job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker:20.10.16-dind&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># variables available to the job&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">DOCKER_TLS_CERTDIR&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/certs&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">IMAGE_TAG&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># some setup scripts -- here, just making sure docker is available&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">before_script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker info&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># meat of the job -- authentication, building, run, push&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker build &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_DEPLOY_USER=$CI_DEPLOY_USER &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_DEPLOY_PASSWORD=$CI_DEPLOY_PASSWORD &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_JOB_TOKEN=$CI_JOB_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>-&lt;span class="l">t $IMAGE_TAG .&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker run $IMAGE_TAG&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker push $IMAGE_TAG&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="c"># job conditions&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$CI_PIPELINE_SOURCE == &amp;#39;merge_request_event&amp;#39; &amp;amp;&amp;amp; $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == &amp;#34;main&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This job is associated with a new stage called &lt;code>build-image&lt;/code> that will run as the last stage of this example CI/CD pipeline. Without going into the specifics of the
&lt;a href="https://kristianeschenburg.netlify.app/post/cicd-2-images-and-registries/">Dockerfile&lt;/a> just yet, this stage builds and pushes a Docker image to a remote repository. We defined the job image as &lt;code>docker:20.10.16&lt;/code> and an additional “service” attribute as &lt;code>docker:20.10.16-dind&lt;/code> where “dind” means “Docker-in-Docker”. The Docker-in-Docker feature allows an image to run Docker itself (pretty meta, huh). The idea here is to instantiate a job from a specific Docker container (“image”) which itself has Docker installed (“dind”), which will allow the docker:20.10.16 image to build &lt;em>another&lt;/em> Docker container. We’ve also defined some variables:&lt;/p>
&lt;ul>
&lt;li>&lt;code>DOCKER_TLS_CERTDIR&lt;/code>: needed to allow the larger scale image to communicate with a service (honestly, I don’t quite understand this and documentation on CI/CD &amp;ldquo;services&amp;rdquo; is sparse)&lt;/li>
&lt;li>&lt;code>IMAGE_TAG&lt;/code>: refers to the container destination and the image “name” &amp;ndash; this just makes our lives easier by turning into a variable what would otherwise be a really long string&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="c">#### Build Docker image and push to Gitlab Container Registry&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">build-image-glcr&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">build-image&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">docker:20.10.16&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">services&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker:20.10.16-dind&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">DOCKER_TLS_CERTDIR&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;/certs&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">IMAGE_TAG&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$CI_REGISTRY_IMAGE:$CI_COMMIT_REF_SLUG&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Next up, we have all the script stuff. You’ll probably recognize most of the &lt;code>docker ${command}&lt;/code> commands. We first “authenticate” the current job with the Gitlab container registry (apparently there are a variety of ways to “authenticate” with Docker using one set of variables or another – the way below is the way I was able to get working, but there are
&lt;a href="https://stackoverflow.com/questions/61251622/how-to-authenticate-to-gitlabs-container-registry-before-building-a-docker-imag" target="_blank" rel="noopener">other solutions&lt;/a>). We then build the Docker image, run the image, and push the image to the container registry.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker build &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_DEPLOY_USER=$CI_DEPLOY_USER &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_DEPLOY_PASSWORD=$CI_DEPLOY_PASSWORD &lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>--&lt;span class="l">build-arg CI_JOB_TOKEN=$CI_JOB_TOKEN&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>-&lt;span class="l">t $IMAGE_TAG .&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker run $IMAGE_TAG&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">docker push $IMAGE_TAG&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You’ll notice a bunch of variables that I didn&amp;rsquo;t explicitly define anywhere:&lt;/p>
&lt;ul>
&lt;li>&lt;code>CI_REGISTRY_USER&lt;/code>: username for project (I think we can also use &lt;code>CI_DEPLOY_USER&lt;/code>, though it looks like there might be some things to figure out with branch / variable protections / non-protections)&lt;/li>
&lt;li>&lt;code>CI_REGISTRY_PASSWORD&lt;/code>: defaults to &lt;code>CI_JOB_TOKEN&lt;/code> value (this value is ephemeral e.g. valid only for one job at a time I think? I think we can also use the &lt;code>CI_DEPLOY_PASSWORD&lt;/code> for a longer-lived alternative, though it looks like there might be some things to figure out with branch / variable protections / non-protections)&lt;/li>
&lt;li>&lt;code>CI_REGISTRY&lt;/code>: defaults to &lt;code>https://gitlab.com/${group}/${project-name}/container_registry&lt;/code>&lt;/li>
&lt;li>&lt;code>CI_DEPLOY_USER&lt;/code>: generated user token&lt;/li>
&lt;li>&lt;code>CI_DEPLOY_PASSWORD&lt;/code>: generated token password&lt;/li>
&lt;li>&lt;code>CI_JOB_TOKEN&lt;/code>: see documentation
&lt;a href="https://docs.gitlab.com/ee/ci/jobs/ci_job_token.html" target="_blank" rel="noopener">here&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>These are all
&lt;a href="https://docs.gitlab.com/ee/ci/variables/predefined_variables.html" target="_blank" rel="noopener">“predefined” variables&lt;/a>, meaning they already exist in the Gitlab CI/CD context as part of having a
&lt;a href="https://www.gitlab.com" target="_blank" rel="noopener">www.gitlab.com&lt;/a> account, without you explicitly defining them. However, I did run into some issues using &lt;code>CI_DEPLOY_USER&lt;/code> and &lt;code>CI_DEPLOY_PASSWORD&lt;/code>. In addition to having predefined variables provided by Gitlab CI/CD, we can also
&lt;a href="./docs/SettingEnvVariables.md">&lt;em>manually&lt;/em> predefine variables&lt;/a> for a whole Gitlab Project or for a whole Group. Go to the page for your &lt;strong>Project/Group &amp;gt; Settings &amp;gt; CI/CD &amp;gt; Variables &amp;gt; Expand&lt;/strong>. For example, I defined the &lt;code>CI_DEPLOY_USER&lt;/code> and &lt;code>CI_DEPLOY_PASSWORD&lt;/code> variables for my Group. These variables are the Group-level authentication tokens and are now made accessible to all Gitlab CI/CD jobs running under this Group. Additionally, although not shown here (because it&amp;rsquo;s used within the &lt;code>setup_tokens.sh&lt;/code> script), we&amp;rsquo;ve also defined an environment variable called &lt;code>PACKAGE_REGISTRY_ID&lt;/code> that tells the CI/CD pipeline where to build and push packages.&lt;/p>
&lt;p>And finally, what we’ve been waiting for, conditional pipeline jobs:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">rules&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">$CI_PIPELINE_SOURCE == &amp;#39;merge_request_event&amp;#39; &amp;amp;&amp;amp; $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == &amp;#34;main&amp;#34;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>As part of this job, I&amp;rsquo;ve defined a rule that indicates that this job should &lt;strong>only&lt;/strong> be run 1) upon a merge request event (i.e. if you click “Create Merge Request” in the console), and 2) if the name of the target branch of that merge request event is &lt;code>main&lt;/code>. For example, if I create a new branch called &lt;code>dev&lt;/code> off of &lt;code>main&lt;/code> and create a merge request in the Gitlab console, this job will run. However, it’s important to note that, if you push a commit to the &lt;code>dev&lt;/code> branch &lt;em>after&lt;/em> creating the merge request, this job will &lt;em>still&lt;/em> run, even if you haven’t created a &lt;em>new&lt;/em> merge request e.g. &lt;code>CI_PIPELINE_SOURCE&lt;/code> == &amp;ldquo;merge_request_event&amp;rdquo; will always default to &lt;code>TRUE&lt;/code> after the first merge request event, as long as the source branch is still actively being developed. Additionally, the job itself for this &lt;strong>will run the source branch code, not the target branch code&lt;/strong>. For Gitlab Premium users, there is an additional criterion called a “merged_result_event”, which would run the target branch (&lt;code>main&lt;/code>) code after merging the source branch (&lt;code>dev&lt;/code>) into the target branch.&lt;/p>
&lt;h2 id="building-and-pushing-a-package">Building and pushing a package&lt;/h2>
&lt;p>Building a package locally is straightforward, but doing so within a Gitlab CI/CD pipeline is a little more complicated. But, we can imagine adding this type of task to a CI/CD pipeline, and conditioning it on a merge request (or something of that kind).&lt;/p>
&lt;p>I&amp;rsquo;ve called this job &lt;code>build-package&lt;/code> and it belongs to a stage also called &lt;code>build-package&lt;/code>. We first set up the &lt;code>.pypirc&lt;/code> and &lt;code>.netrc&lt;/code> files in image running the job, and then install the &lt;code>build&lt;/code> and &lt;code>twine&lt;/code> libraries in the &lt;code>before_script&lt;/code> attribute. Then, using the &lt;code>script&lt;/code> attribute, we build our package, and push it to our package registry (we&amp;rsquo;ve defined the registry in our &lt;code>.pypirc&lt;/code> file &amp;ndash; here, it&amp;rsquo;s referred to as the &amp;ldquo;personal&amp;rdquo; registry.)&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">image&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">python:3.9-slim&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">variables&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">PACKAGE_REGISTRY_NAME&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s2">&amp;#34;personal&amp;#34;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">stages&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">build-package&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="c">#### BUILDING PACKAGE AND PUSHING TO GITLAB PACKAGE REGISTRY&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">build-package&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">stage&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">build-package&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">before_script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">chmod +x ./setup_tokens.sh; ./setup_tokens.sh&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get update&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apt-get install --yes --no-install-recommends gcc g++ libffi-dev&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m pip install build twine&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m build&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">python3 -m twine upload --repository ${PACKAGE_REGISTRY_NAME} dist/* --verbose&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Some of the (many) external links I used in this process:&lt;/p>
&lt;ul>
&lt;li>
&lt;a href="https://docs.gitlab.com/ee/ci/variables/predefined_variables.html" target="_blank" rel="noopener">Predefined&lt;/a> CI/CD variables&lt;/li>
&lt;li>
&lt;a href="https://gitlab.com/gitlab-org/gitlab/-/issues/214014" target="_blank" rel="noopener">Adding&lt;/a> variables to the Gitlab CI/CD context&lt;/li>
&lt;li>
&lt;a href="https://gitlab.com/gitlab-org/gitlab/-/issues/350582" target="_blank" rel="noopener">Setting&lt;/a> up a ~/.netrc file with Gitlab credentials&lt;/li>
&lt;li>
&lt;a href="https://stackoverflow.com/questions/72789599/gitlab-ci-cd-execute-script-file-that-exist-in-the-repository" target="_blank" rel="noopener">Executing&lt;/a> a bash scripting within a Gitlab CI/CD pipeline&lt;/li>
&lt;li>
&lt;a href="https://stackoverflow.com/questions/58939500/how-to-pass-gitlab-ci-file-variable-to-dockerfile-and-docker-container" target="_blank" rel="noopener">Passing&lt;/a> variables to Docker image at build time&lt;/li>
&lt;li>
&lt;a href="https://www.shellhacks.com/gitlab-ci-cd-build-docker-image-push-to-registry/" target="_blank" rel="noopener">Building&lt;/a> Docker image and pushing to registry&lt;/li>
&lt;li>
&lt;a href="https://docs.gitlab.com/ee/user/packages/container_registry/authenticate_with_container_registry.html" target="_blank" rel="noopener">Authenticating&lt;/a> container registries&lt;/li>
&lt;li>
&lt;a href="https://docs.gitlab.com/ee/user/packages/pypi_repository/#authenticate-with-the-package-registry" target="_blank" rel="noopener">Setting&lt;/a> up a ~/.pypirc files with Gitlab credentials&lt;/li>
&lt;/ul></description></item><item><title>Lab Meeting: pip and the Python Packaging Index</title><link>https://kristianeschenburg.netlify.app/post/pyni-packages/</link><pubDate>Sun, 07 Jun 2020 10:20:09 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/pyni-packages/</guid><description>&lt;p>What follows are the contents of part of a lab meeting presentation I gave recently. The topic of the meeting was &amp;ldquo;Python for Neuroimaging&amp;rdquo;, where I covered basic software development tools that brain imaging scientists might be interested in.&lt;/p>
&lt;h1 id="creating-python-packages">Creating Python Packages&lt;/h1>
&lt;p>In this lesson, I&amp;rsquo;ll show you how to build your own Python package that you can then install locally or upload to the
&lt;a href="https://pip.pypa.io/en/stable/" target="_blank" rel="noopener">Python Packaging Index&lt;/a> (for those of you familiar with
&lt;a href="https://www.r-project.org/about.html" target="_blank" rel="noopener">R&lt;/a>, think
&lt;a href="https://cran.r-project.org/" target="_blank" rel="noopener">CRAN&lt;/a>, but for Python).&lt;/p>
&lt;p>I&amp;rsquo;m going to be basing a lot of the material off of this
&lt;a href="https://packaging.python.org/tutorials/packaging-projects/" target="_blank" rel="noopener">documentation&lt;/a>, but will also show a real example using some of my own personal code.&lt;/p>
&lt;h2 id="what-are-packages">What are packages?&lt;/h2>
&lt;p>I&amp;rsquo;m sure most of you are familiar with packages and libraries already, either from Matlab, R, or Python. Packages are basically bundles of various snippets of code, i.e. &lt;strong>methods&lt;/strong>, &lt;strong>classes&lt;/strong>, &lt;strong>scripts&lt;/strong>, &lt;strong>tests&lt;/strong> etc. that are bundled together to perform some function. Generally (hopefully), there is coherence to what these snippets of code do &amp;ndash; they should interact together in some way or relate to some overarching computational goal.&lt;/p>
&lt;p>Within a package, you can have different groupings of code, where each grouping does some unique or discrete computing. These groupings are called &lt;strong>submodules&lt;/strong>. A common submodule in many packages is an &lt;strong>Input / Output (io)&lt;/strong> module that will read and write data that this package interacts with or produces. Another common submodule is often related to &lt;strong>plotting&lt;/strong> the outputs of your code. And then almost always, there are submodules that perform the brunt of the algorithmic work. So inside modules, you&amp;rsquo;ll find snippets of code that relate to the goal or concept of the module.&lt;/p>
&lt;p>Think of a package as a &lt;em>toolbox&lt;/em> with a bunch of drawers, each with a label: &lt;em>wood-working&lt;/em>, &lt;em>welding&lt;/em>, &lt;em>gardening&lt;/em>, &lt;em>flooring&lt;/em>, etc. These drawers are submodules. You can tell by their names that they each cover certain topics. Each drawer contains a set of tools: &lt;em>wood-working&lt;/em> might contain &lt;em>saw&lt;/em>, &lt;em>nail&lt;/em>, &lt;em>sandpaper&lt;/em>, &lt;em>wood glue&lt;/em>, while &lt;em>welding&lt;/em> might contain &lt;em>solder&lt;/em>, &lt;em>flux&lt;/em>, &lt;em>oxygen&lt;/em>, &lt;em>glove&lt;/em>. These tools are the functions, classes, and scripts that relate to that submodule.&lt;/p>
&lt;p>Overall, this toolbox performs some stuff related to construction, homebuilding, repair, and has discrete bundles of code useful for a variety of those tasks.&lt;/p>
&lt;h3 id="directory-structure-for-a-python-package">Directory structure for a Python package&lt;/h3>
&lt;p>Here we examine the skeleton of a package. All packages follow this basic structure.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pkg_name
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- __init__.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- LICENSE
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- pkg_name/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- submodule_a/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- __init__.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- a_1.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- submodule_b/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- __init__.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- b_1.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- b_2.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- README.md
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- setup.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span>-- test/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">|&lt;/span> &lt;span class="p">|&lt;/span>-- __init__.py&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>__init__.py&lt;/code> is a required file that allows your package to be imported. The only &lt;code>__init__.py&lt;/code> file that needs to contain anything is the highest-level file. The others can be empty, but they must exist. Here are the contents of the highest-level &lt;code>__init__.py&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">__all__&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;a_1&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;b_1&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;b_2&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">.submodule_a&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">a_1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">.submodule_b&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">b_1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">b_2&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>LICENSE&lt;/code> tells other users / individuals in what capacity they are allowed to use your code.&lt;/p>
&lt;p>&lt;code>README.md&lt;/code> describes how to use your code, and often contains examples. This is a &lt;strong>markdown&lt;/strong> file, but can generally be any type of &lt;strong>markup&lt;/strong> language.&lt;/p>
&lt;p>&lt;code>test/&lt;/code> is a directory in which you would want to write
&lt;a href="http://softwaretestingfundamentals.com/unit-testing/" target="_blank" rel="noopener">unit tests&lt;/a> for your code.&lt;/p>
&lt;p>&lt;code>setup.py&lt;/code> is what allows you to install your package. It&amp;rsquo;s a set of instructions that get supplied to
&lt;a href="https://setuptools.readthedocs.io/en/latest/" target="_blank" rel="noopener">setuptools&lt;/a> package.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">setuptools&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">setup&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">find_packages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;README.md&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;r&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">fh&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">long_description&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">fh&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">setup&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;pkg_name&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;0.1.0&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">author&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;Kristian M. Eschenburg&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">author_email&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;keschenb@uw.edu&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">packages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">find_packages&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">scripts&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;https://github.com/kristianeschenburg/pkg_name&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">license&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;LICENSE.txt&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;An awesome package that does something&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">long_description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">long_description&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">install_requires&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;numpy&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;pytest&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;matplotlib&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="compiling-installing-and-uploading-your-package">Compiling, installing, and uploading your package&lt;/h3>
&lt;p>&lt;strong>1. Register on PyPi&lt;/strong>&lt;/p>
&lt;p>Once we&amp;rsquo;ve done all this, we&amp;rsquo;re just about ready to create our Python package and upload it to
&lt;a href="https://pypi.org/" target="_blank" rel="noopener">pypi.org&lt;/a>. But first, we need to create an account. For testing purposes, we&amp;rsquo;ll create a test account
&lt;a href="https://test.pypi.org/" target="_blank" rel="noopener">here&lt;/a>, but the process is the same.&lt;/p>
&lt;p>After you create your account, we need to create an API token, that will allow us to upload files to either
&lt;a href="https://test.pypi.org/" target="_blank" rel="noopener">Test PyPi&lt;/a> or
&lt;a href="https://pypi.org/" target="_blank" rel="noopener">PyPi&lt;/a> (depending on what we&amp;rsquo;re doing) &amp;ndash; the following steps are the same, regardless.&lt;/p>
&lt;p>Under your Test PyPi account, click your username in the top right, go to &lt;code>Account Settings&lt;/code>:&lt;/p>
&lt;figure >
&lt;a data-fancybox="" href="notebook_figures/packages/PyPi_Account.png" >
&lt;img src="notebook_figures/packages/PyPi_Account.png" alt="" >
&lt;/a>
&lt;/figure>
&lt;p>Scroll down and click &lt;code>Add API Token&lt;/code>:&lt;/p>
&lt;figure >
&lt;a data-fancybox="" href="notebook_figures/packages/PyPi_Token.png" >
&lt;img src="notebook_figures/packages/PyPi_Token.png" alt="" >
&lt;/a>
&lt;/figure>
&lt;p>Follow the instructions there, making sure to select &amp;ldquo;Entire Account&amp;rdquo; option under the &lt;code>Scope&lt;/code> tab.&lt;/p>
&lt;p>&lt;strong>DO NOT CLOSE THIS WINDOW WHEN THIS IS COMPLETE&lt;/strong>&lt;/p>
&lt;p>Next, type&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nb">cd&lt;/span> &lt;span class="nv">$HOME&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">touch .pypirc&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>and using your favorite text editor, enter the following:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-txt" data-lang="txt">&lt;span class="line">&lt;span class="cl">[testpypi]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> repository: https://test.pypi.org/legacy/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> username = __token__
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> password = pypi-***&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If we were creating a token for PyPi, we&amp;rsquo;d type:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-txt" data-lang="txt">&lt;span class="line">&lt;span class="cl">[pypi]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> repository: https://pypi.org/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> username = __token__
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> password = pypi-***&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Close your current terminal, and open a new window to refresh your settings. Now, when we go to upload our package to PyPi, we&amp;rsquo;ll be able to type the commands without needed to supply a username and password directly.&lt;/p>
&lt;p>&lt;strong>2. Compile your package&lt;/strong>&lt;/p>
&lt;p>First, we need to make sure that a few Python packages are installed. Namely, we need to install
&lt;a href="https://pip.pypa.io/en/stable/" target="_blank" rel="noopener">pip&lt;/a>&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">curl https://bootstrap.pypa.io/get-pip.py -o get-pip.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python get-pip.py
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install -U pip&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then we can install the following packages:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pip install --upgrade pip setuptools wheel &lt;span class="c1"># for installing Python packages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install tqdm &lt;span class="c1"># progress bar package&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">pip install --user --upgrade twine &lt;span class="c1"># for publishing to PyPi&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Then, we can compile our package:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python setup.py bdist_wheel&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>which creates the directories &lt;code>dist&lt;/code>, &lt;code>build&lt;/code>, and &lt;code>pkg_name.egg-info&lt;/code>. The &lt;code>*.egg-info&lt;/code> file is basically some zipped meta-data about your package, but we&amp;rsquo;re really only interested in the &lt;code>*.whl&lt;/code> file in &lt;code>dist&lt;/code> &amp;ndash; &amp;ldquo;wheels&amp;rdquo; are a &amp;ldquo;distribution&amp;rdquo; format, newly designed to replace &amp;ldquo;eggs&amp;rdquo;. I won&amp;rsquo;t go into it here, but &lt;strong>eggs&lt;/strong> were sort an &lt;em>ad hoc&lt;/em> solution to packaging Python code &amp;ndash; &lt;strong>wheels&lt;/strong> were part of
&lt;a href="https://www.python.org/dev/peps/pep-0427/" target="_blank" rel="noopener">PEP427&lt;/a> i.e. is actually an &amp;ldquo;enhancement&amp;rdquo; to the Python language, and the formal way of packaging Python code.&lt;/p>
&lt;p>&lt;strong>3. Installing your code&lt;/strong>&lt;/p>
&lt;p>We can install our code locally with:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pip install dist/pkg_name-0.0.0-py3-none-any.whl&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>If you want to install an &amp;ldquo;editable&amp;rdquo; version of your package, do this:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pip install -e .&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>This will allow you to change your &lt;code>*.py&lt;/code> files and have these changes take effect immediately when importing your package, without needing to rebuild each time &amp;ndash; but this method installs from the &lt;strong>egg&lt;/strong> distribution, and generally produces larger build files, since the build needs to keep track of your actual source code.&lt;/p>
&lt;p>&lt;strong>4. Upload your code&lt;/strong>&lt;/p>
&lt;p>We can upload our code to PyPi now using the following command:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python3 -m twine upload --repository testpypi dist/*&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now, if you click &amp;ldquo;Your Projects&amp;rdquo; under your account name on PyPi, you&amp;rsquo;ll see that you project has been uploaded.&lt;/p>
&lt;p>** I should note that, any time you want to upgrade your code and upload it to PyPi again, you need to remove all files from the &lt;code>dist&lt;/code> directory, increment the &lt;code>version&lt;/code> number in the &lt;code>setup.py&lt;/code> file &amp;ndash; i.e. 0.0.0 &amp;ndash;&amp;gt; 0.0.1 &amp;ndash; rebuild your package with&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="n">bash&lt;/span> &lt;span class="n">python&lt;/span> &lt;span class="n">setup&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">py&lt;/span> &lt;span class="n">bdist_wheel&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="example-with-personal-package">Example with personal package&lt;/h3>
&lt;p>I don&amp;rsquo;t generally upload my code to PyPi (probably scared bugs in the code, and people finding them, and then thinking I&amp;rsquo;m terrible at software development, and going down a long spiral of self-deprecation, but I digress&amp;hellip;) but I do upload it all to GitHub. In either case, here is a walk-through of packaging some software called &lt;code>pysurface&lt;/code> that I use for processing mesh-based data &amp;ndash; I use it for adjacency matrices, performing Laplacian smoothing on surfaces, sampling points from mesh triangle simplices, plotting on surfaces&amp;hellip; Just some stuff that I find myself doing a lot.&lt;/p>
&lt;p>Here is the directory containing all my code:&lt;/p>
&lt;figure >
&lt;a data-fancybox="" href="notebook_figures/packages/pysurface_code.png" >
&lt;img src="notebook_figures/packages/pysurface_code.png" alt="" >
&lt;/a>
&lt;/figure>
&lt;p>You&amp;rsquo;ll see 5 different modules: &lt;code>graphs&lt;/code>, &lt;code>operations&lt;/code>, &lt;code>plotting&lt;/code>, &lt;code>spectra&lt;/code>, and &lt;code>utilities&lt;/code>, and you&amp;rsquo;ll note that each module directory has a &lt;code>__init__.py&lt;/code> file.&lt;/p>
&lt;p>Here is my &lt;code>setup.py&lt;/code> file:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">os&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">setuptools&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">setup&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">find_packages&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">sys&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">here&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">abspath&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">dirname&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="vm">__file__&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">here&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;README.rst&amp;#39;&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">encoding&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;utf-8&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">readme_file&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">readme&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">readme_file&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="nb">open&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">join&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">here&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;requirements.txt&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">requirements_file&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Parse requirements.txt, ignoring any commented-out lines.&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">requirements&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">line&lt;/span> &lt;span class="k">for&lt;/span> &lt;span class="n">line&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">requirements_file&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">read&lt;/span>&lt;span class="p">()&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">splitlines&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">line&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">startswith&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;#&amp;#39;&lt;/span>&lt;span class="p">)]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">setup&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;pysurface&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">version&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;0.0.4&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Python package for quickly processing surface meshes.&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">long_description&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">readme&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">author&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;Kristian Eschenburg&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">author_email&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;keschenb@uw.edu&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">url&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;https://github.com/kristianeschenburg/pysurface&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">packages&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">find_packages&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">entry_points&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;console_scripts&amp;#39;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># &amp;#39;some.module:some_function&amp;#39;,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">include_package_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">package_data&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;pysurface&amp;#39;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># When adding files here, remember to update MANIFEST.in as well,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># or else they will not be included in the distribution on PyPI!&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># &amp;#39;path/to/data_file&amp;#39;,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">install_requires&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">requirements&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">license&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;BSD (3-clause)&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">classifiers&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Development Status :: 2 - Pre-Alpha&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Natural Language :: English&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;Programming Language :: Python :: 3&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>You can see that I&amp;rsquo;ve run&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">python setup.py bdist_wheel&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>based off the &lt;code>dist&lt;/code>, &lt;code>build&lt;/code>, and &lt;code>pysurface.egg-info&lt;/code> directories. &lt;code>dist&lt;/code> contains a file called &lt;code>pysurface-0.0.4-py3-none.any.whl&lt;/code>, which is the actual distribution that can be used for installation. I&amp;rsquo;ve uploaded the code to Test PyPi, and this is what we see:&lt;/p>
&lt;figure >
&lt;a data-fancybox="" href="notebook_figures/packages/PyPi_Project.png" >
&lt;img src="notebook_figures/packages/PyPi_Project.png" alt="" >
&lt;/a>
&lt;/figure>
&lt;p>We can then install the package and all of it&amp;rsquo;s dependencies from TestPypi via&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">pip install --index-url https://test.pypi.org/simple/ pysurface&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Now I can do something like the following in a Python script:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">pysurface&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pysurface&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">graphs&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">spectra&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># or&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pysurface.spectra&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">eigenspectrum&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description></item><item><title>Submitting Batch Jobs with qsub</title><link>https://kristianeschenburg.netlify.app/post/submitting-batch-jobs-with-qsub/</link><pubDate>Tue, 05 May 2020 14:24:17 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/submitting-batch-jobs-with-qsub/</guid><description>&lt;p>I&amp;rsquo;m fortunate enought to work in a lab with some high-level computing infrastructure. We have a cluster of machines using the
&lt;a href="http://bioinformatics.mdc-berlin.de/intro2UnixandSGE/sun_grid_engine_for_beginners/README.html" target="_blank" rel="noopener">Sun Grid Engine&lt;/a> (SGE) software system for distributed resource management. The other day, I was searching for how to wrap my Python scripts with &lt;code>qsub&lt;/code> so that I could submit a batch of jobs to our cluster. Eventually, I want to be able to submit jobs with dependencies between them, but we&amp;rsquo;ll start here.&lt;/p>
&lt;p>Let&amp;rsquo;s create an example script that computes the mean of an MRI image. Let&amp;rsquo;s call the script &lt;code>compute_mean.py&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="ch">#!/usr/bin/env python&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">argparse&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">nibabel&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">nb&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">pandas&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">pd&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">parser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">ArgumentParser&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Compute the mean of MRI, and save to CSV file.&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">parser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_argument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;-i&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;--input_image&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">help&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;Path to MRI image.&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">parser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">add_argument&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;-o&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;--output_csv&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">help&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;Output CSV file.&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">required&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nb">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">args&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">parser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parse_args&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># read in image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">img&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">nb&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">input_image&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># get voxel-wise data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">img&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_data&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># compute mean&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">mu&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mean&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># save to csv&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">df&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">pd&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">DataFrame&lt;/span>&lt;span class="p">({&lt;/span>&lt;span class="s1">&amp;#39;mean&amp;#39;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="n">mu&lt;/span>&lt;span class="p">]})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">df&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">to_csv&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">args&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">output_csv&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The first line of this script, &lt;code>#!/usr/bin/env python&lt;/code> tells the script to use the local &lt;code>python&lt;/code> environment. In my case, I have a customized installation of Python, along with a bunch packages and libraries that I&amp;rsquo;ve written and installed that are not available for the rest of my lab (since they&amp;rsquo;re still in the testing phase or just something I&amp;rsquo;m experimenting with). This line tells the script to use &lt;em>my&lt;/em> Python environment, rather than the default version on our servers.&lt;/p>
&lt;p>We can then create a bash wrapper, let&amp;rsquo;s call in &lt;code>mean_wrapper.sh&lt;/code> for a single subject&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>&lt;span class="c1">#$ -M keschenb@uw.edu&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#$ -m abe&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#$ -r y&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#$ -o tmp.out&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">#$ -e tmp.err&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Compute mean of image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">image&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">output&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">python compute_mean.py &lt;span class="si">${&lt;/span>&lt;span class="nv">image&lt;/span>&lt;span class="si">}&lt;/span> &lt;span class="si">${&lt;/span>&lt;span class="nv">output&lt;/span>&lt;span class="si">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>The second and third line here, with the &lt;code>M&lt;/code> and &lt;code>m&lt;/code> parameters, tell the script to email me once it completes the processing (or if there are any errors). And finally, we can create a wrapper that takes in a list of subjects to process, and the input and output directories, and submits each individual job to the queue using &lt;code>qsub&lt;/code>:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="cp">#!/bin/bash
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="cp">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">subjects&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">image_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">output_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$3&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># we create a variable, as our cluster has 2 different queues to use&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># this could be hardcoded though&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nv">queue_name&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nv">$4&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="nb">read&lt;/span> subj
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">do&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nv">image_file&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">image_dir&lt;/span>&lt;span class="si">}${&lt;/span>&lt;span class="nv">subj&lt;/span>&lt;span class="si">}&lt;/span>.nii.gz
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nv">output_file&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nv">output_dir&lt;/span>&lt;span class="si">}${&lt;/span>&lt;span class="nv">subj&lt;/span>&lt;span class="si">}&lt;/span>.csv
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> qsub -q &lt;span class="si">${&lt;/span>&lt;span class="nv">queue_name&lt;/span>&lt;span class="si">}&lt;/span>.q mean_wrapper.sh &lt;span class="si">${&lt;/span>&lt;span class="nv">input_image&lt;/span>&lt;span class="si">}&lt;/span> &lt;span class="si">${&lt;/span>&lt;span class="nv">output_file&lt;/span>&lt;span class="si">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">done&lt;/span> &amp;lt;&lt;span class="si">${&lt;/span>&lt;span class="nv">subjects&lt;/span>&lt;span class="si">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Here&amp;rsquo;s an example output from running &lt;code>qstat&lt;/code> after submitting a batch of jobs to the cluster:&lt;/p>
&lt;figure id="figure-example-of-qstat-command-after-submitting-jobs-view-qsub">
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/submitting-batch-jobs-with-qsub/qsub_hu_f5f7230e53b0ace3.png" data-caption="Example of qstat command, after submitting jobs view qsub.">
&lt;img data-src="https://kristianeschenburg.netlify.app/post/submitting-batch-jobs-with-qsub/qsub_hu_f5f7230e53b0ace3.png" class="lazyload" alt="" width="799" height="323">
&lt;/a>
&lt;figcaption>
Example of qstat command, after submitting jobs view qsub.
&lt;/figcaption>
&lt;/figure>
&lt;p>One thing you&amp;rsquo;ll notice is the column &lt;code>priority&lt;/code> &amp;ndash; this is literally a &lt;code>priority queue&lt;/code> data structure that I mentioned in my last post on the Watershed by Flooding algorithm. Each job is submitted to the queue with a priority value assigned to it by the SGE software, and the jobs are processed in that order &amp;ndash; highest priority first, lowest priority last. Your IT manager can personalize the priority values for specific users or types of jobs, such that they are given preference or moved back in line. This represents an equitable way of distributing compute resources across users in a lab, generally using a first-come, first-serve basis, or restricting users to a certain number of nodes.&lt;/p></description></item><item><title>Watershed by Flooding: Applied Data Structures</title><link>https://kristianeschenburg.netlify.app/post/watershed-by-flooding-applied-data-structures/</link><pubDate>Fri, 01 May 2020 11:12:32 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/watershed-by-flooding-applied-data-structures/</guid><description>&lt;p>I&amp;rsquo;m applying some methods developed in
&lt;a href="https://www.ncbi.nlm.nih.gov/pmc/articles/PMC4677978/pdf/bhu239.pdf" target="_blank" rel="noopener">this paper&lt;/a> for testing purposes in my own thesis research. Specifically, I have some float-valued data, $F$, that varies along the cortical surface of the brain. Visually, I can see that there are areas where these scalar maps change abruptly. I want to identify these boundaries &amp;ndash; eventually, I&amp;rsquo;ll segment out the regions I&amp;rsquo;m interested in.&lt;/p>
&lt;h3 id="computing-the-gradient-map">Computing the Gradient Map&lt;/h3>
&lt;p>The authors use some conventional brain imaging software to compute the gradient of their data. The domain of this data is a triangulated mesh, described by the graph $G = (V, E)$, where $V$ are vertices in Euclidean space and $E$ the edges between these vertices. In short, for a vertex, $v_{i}$, we first need to compute the gradient vector of the scalar field at $v_{i}$. We &amp;ldquo;unfold&amp;rdquo; the 3D positions of adjacent vertices onto the tangent plane of $v_{i}$ &amp;ndash; which we can do by orthogonally projecting the adjacent vertices onto the affine subspace at $v_{i}$ and then weighting appropriately. We then regress the graph signal (our scalar field) onto these unfolded positions. The $L_{2}$ norm of this vector is the gradient at $v_{i}$.&lt;/p>
&lt;p>For each vertex, we have a normal vector to the surface $N$, its spatial 3D coordinates $v_{i} = (x, y, z)$, and a list of its adjacent vertices. We can compute the orthogonal projector onto the affine subspace spanned by $N$, $P_{N}$, and its orthogonal complement, $Q_{N}$, as:&lt;/p>
&lt;p>$$\begin{align}
P_{N} &amp;amp;= N(N^{T}N)^{-1}N^{T} \\
Q_{N} &amp;amp;= I - P_{N}
\end{align}$$&lt;/p>
&lt;p>For any vertex, $v_{j}$, we can compute the orthogonal projection onto the affine subspace spanned by $Q_{N}$ as:&lt;/p>
&lt;p>$$\begin{align}
q(v_{j}) = Q_{N}(v_{j} - v_{i}) + v_{i}
\end{align}$$&lt;/p>
&lt;p>We generate the vectors&lt;/p>
&lt;p>$$
S_{i} = f(i) -
\begin{bmatrix}
f(v_{1}) \\
f(v_{2}) \\
\vdots \\
f(v_{j})
\end{bmatrix}
\in \mathbb{R}^{j} \;\;\;
R_{i} =
\begin{bmatrix}
q(v_{1}) \\
q(v_{2}) \\
\vdots \\
q(v_{j})
\end{bmatrix} \in \mathbb{R}^{j \times 3}
$$&lt;/p>
&lt;p>where $S_{i}$ is the difference between the scalar value at our vertex $f(v_{i})$ and the vector of adjacent $j$ scalar field values, and $R_{i}$ is the matrix of $j$ orthogonally projected adjacent vertex coordinates. Then we perform least squares regression to solve for $\beta$:&lt;/p>
&lt;p>$$\begin{align}
S_{i} = R_{i}\beta
\end{align}$$&lt;/p>
&lt;p>where $\beta \in \mathbb{R}^{3}$, which indicates how much each coordinate axis contributes to variation in the scalar field at $v_{i}$. The gradient value at vertex $v_{i} = \left || \beta \right||_{2}$.&lt;/p>
&lt;h3 id="watershed-by-flooding-algorithm">Watershed By Flooding Algorithm&lt;/h3>
&lt;p>The new scalar field of $L_{2}$ norms is our gradient field, which describes how &amp;ldquo;quickly&amp;rdquo; our original data changes at each vertex. We can now apply the
&lt;a href="https://en.wikipedia.org/wiki/Watershed_%28image_processing%29" target="_blank" rel="noopener">Watershed Algorithm&lt;/a> to segment our mesh data. In brief, the watershed algorithm treats the gradient field as a &lt;em>topographic map&lt;/em>: low-elevation areas (areas with a small gradient) are &amp;ldquo;water basins&amp;rdquo;. If we imagine water flooding this map from the bottom up, basins at low elevation will flood first, while areas at higher elevations will fill last. When water basins meet, the water has reached a &amp;ldquo;boundary&amp;rdquo; (or ridgeline, if we&amp;rsquo;re using the topographic map idea).&lt;/p>
&lt;p>I&amp;rsquo;ve implemented an algorithm variant called
&lt;a href="https://www.sciencedirect.com/science/article/pii/S0098300418307957" target="_blank" rel="noopener">&amp;ldquo;Priority Flooding&amp;rdquo;&lt;/a>, using Python&amp;rsquo;s
&lt;a href="https://docs.python.org/2/library/heapq.html" target="_blank" rel="noopener">heapq&lt;/a>
&lt;a href="https://en.wikipedia.org/wiki/Priority_queue" target="_blank" rel="noopener">priority queue&lt;/a> data type class. The priority queue is an application of the
&lt;a href="https://en.wikipedia.org/wiki/Binary_heap" target="_blank" rel="noopener">binary heap&lt;/a> data structure &amp;ndash; as nodes are added to the heap, the branching process determines where to put nodes (left or right of a current node), based on some value &amp;ndash; in the case of the priority queue, this value is the &amp;ldquo;priority&amp;rdquo;. We utilize the priority queue because it gives us a principled way to iterate over unlabeled vertices, and, with some auxiliary data structures, is guaranteed to converge. The algorithm proceeds as follows:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>Identify local minima, and assign each minimum a unique label&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Add directly adjacent vertices of local minima to priority queue (lower gradient -&amp;gt; higher priority)&lt;/p>
&lt;/li>
&lt;li>
&lt;p>While the queue is not empty, get the highest-priority item&lt;/p>
&lt;ul>
&lt;li>
&lt;p>If vertices adjacent to this vertex have only one label, assign this vertex to that label&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Else assign this vertex as a boundary vertex&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Add unlabeled adjacent vertices to the queue&lt;/p>
&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ol>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">numpy&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">np&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">queue&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">PriorityQueue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">class&lt;/span> &lt;span class="nc">PriorityFlood&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">object&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> Class to segment a scalar field using the Priority Flooding
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> watershed algorithm.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="fm">__init__&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pq&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">PriorityQueue&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">def&lt;/span> &lt;span class="nf">fit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">gradient&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">A&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">M&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> Fitting procedure for watershed algorithm.
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> Parameters:
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> - - - - -
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> gradient: float, array
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> map of gradient values of scalar field
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> A: dict of lists
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> adjacency list of data domain
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> M: list
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> local minima in scalar field used to seed algorithm
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s2"> &amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># initialize empty label vector&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">labels&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">zeros&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">gradient&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">labels&lt;/span>&lt;span class="p">[:]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">nan&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># keep track of items in queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in_queue&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">zeros&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">gradient&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">))&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">astype&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">bool&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># assign local minima unique labels&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># add their adjacent vertices to heap&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">loc_min&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="nb">enumerate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">M&lt;/span>&lt;span class="p">):&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># label local minima&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">labels&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">loc_min&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">i&lt;/span>&lt;span class="o">+&lt;/span>&lt;span class="mi">1&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># add neighbors of local minima to p-queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">nidx&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">A&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">loc_min&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in_queue&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">nidx&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pq&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">gradient&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">nidx&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">nidx&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># iterate over p-queue items&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># items assigned to a label or&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># assigned as a boundary&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pq&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">empty&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># get highest priority item&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># mark as not in p-queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">[&lt;/span>&lt;span class="n">gr&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">idx&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pq&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in_queue&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">idx&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">False&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># get item neighbors and their labels&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">i_neighbors&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">asarray&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">A&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">idx&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">i_nlabels&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">labels&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i_neighbors&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># get labels of adjacent vertices that are not nan&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">nans&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">isnan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i_nlabels&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">unique_labels&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">unique&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">i_nlabels&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="o">~&lt;/span>&lt;span class="n">nans&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># if more than one unique label&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># assign current vertex as border vertex&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="nb">len&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">unique_labels&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;gt;&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">continue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># otherwise assign to water basin&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">else&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># basin assignment&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">labels&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">idx&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">unique_labels&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># identify neighbors without labels that arent in the p-queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">gidx&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">where&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">nans&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">&amp;amp;&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="o">~&lt;/span>&lt;span class="n">in_queue&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">i_neighbors&lt;/span>&lt;span class="p">]))[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># add these to p-queue&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">for&lt;/span> &lt;span class="n">nidx&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">i_neighbors&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">gidx&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="n">in_queue&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">nidx&lt;/span>&lt;span class="p">]:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">in_queue&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">nidx&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kc">True&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pq&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">put&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">gradient&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">nidx&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">nidx&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="bp">self&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">labels_&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">labels&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
&lt;figure id="figure-example-of-priorityflooding-algorithm-applied-to-gradient-of-inferiorparietal-region--shown-on-inflated-and-flattened-cortical-surfaces">
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/watershed-by-flooding-applied-data-structures/WSA_hu_22fbb331c3d719ca.jpg" data-caption="Example of PriorityFlooding algorithm, applied to gradient of inferiorparietal region. Shown on inflated and flattened cortical surfaces.">
&lt;img data-src="https://kristianeschenburg.netlify.app/post/watershed-by-flooding-applied-data-structures/WSA_hu_22fbb331c3d719ca.jpg" class="lazyload" alt="" width="1280" height="803">
&lt;/a>
&lt;figcaption>
Example of PriorityFlooding algorithm, applied to gradient of inferiorparietal region. Shown on inflated and flattened cortical surfaces.
&lt;/figcaption>
&lt;/figure>
&lt;p>One caveat that came up is that the &lt;strong>PriorityQueue&lt;/strong> class does not check for duplicates &amp;ndash; that is, two vertices might share an adjacent vertex, and this vertex might have already been added to the heap. In this case, we would unnecessarily view the same vertices many times. To alleviate this, we create a boolean Numpy array, &lt;code>in_queue&lt;/code>, that stores whether an item is already in the queue.&lt;/p></description></item><item><title>Headfirst into (Uncommented) C++</title><link>https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c++/</link><pubDate>Mon, 29 Oct 2018 08:12:32 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c++/</guid><description>&lt;p>While most of my day-to-day research entails writing Python code, I also make heavy use of pre-written software. Most software comes pre-compiled, but whenever possible, I like to get access to the source code. I&amp;rsquo;m going to refer to some modifications I made to pre-existing packages &amp;ndash; you can find those
&lt;a href="https://github.com/kristianeschenburg/ptx3" target="_blank" rel="noopener">in my repository here.&lt;/a>&lt;/p>
&lt;p>The most-commonly used open-source package for brain imaging is called
&lt;a href="https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FSL" target="_blank" rel="noopener">FMRIB Software Library&lt;/a> (FSL), which includes tools for processing MRI data, with applications ranging from motion correction and image registration, to modal decomposition methods, among many others. All of this is made available as a set of pre-compiled C++ binaries.&lt;/p>
&lt;p>I needed to modify FSL&amp;rsquo;s
&lt;a href="https://fsl.fmrib.ox.ac.uk/fsl/fslwiki/FDT/UserGuide#PROBTRACKX_-_probabilistic_tracking_with_crossing_fibres" target="_blank" rel="noopener">probtrackx2&lt;/a> tool. &lt;code>probtrackx2&lt;/code> is a tool for generating probabilistic tractography. Using diffusion MRI, we can model the movement of water in the brain. At the voxel level, diffusion tends to be high when water moves along neuronal axon bundles, and low when moving against the myelin or in the extracellular matrix &amp;ndash; this water movement can be modeled using a variety of approaches.&lt;/p>
&lt;figure id="figure-diffusion-tractography-from-biomedical-image-computing-group-at-usc">
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/tractography_hu_f4f28825bd31c09e.png" data-caption="Diffusion tractography from Biomedical Image Computing Group at USC.">
&lt;img data-src="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/tractography_hu_f4f28825bd31c09e.png" class="lazyload" alt="" width="1194" height="964">
&lt;/a>
&lt;figcaption>
Diffusion tractography from Biomedical Image Computing Group at USC.
&lt;/figcaption>
&lt;/figure>
&lt;p>At the simplest level, the diffusion can be modeled as a
&lt;a href="https://en.wikipedia.org/wiki/Tensor" target="_blank" rel="noopener">diffusion tensor&lt;/a>, where the
&lt;a href="https://en.wikipedia.org/wiki/Eigenvalues_and_eigenvectors" target="_blank" rel="noopener">eigenvalues&lt;/a> of the tensor correspond to the amount of diffusion in the direction of the corresponding eigenvector. At the more complex levels, we can represent the diffusion as a 3D
&lt;a href="https://onlinelibrary.wiley.com/doi/pdf/10.1002/mrm.22365" target="_blank" rel="noopener">probability distribution function&lt;/a>, whose marginal distributions are called &lt;strong>orientation distribution functions&lt;/strong> (ODF), and represent these continuous functions using a
&lt;a href="https://en.wikipedia.org/wiki/Spherical_harmonics" target="_blank" rel="noopener">spherical harmonics&lt;/a> basis set of the ODF. Using &lt;code>probtrackx2&lt;/code>, we can sample these ODFs using a
&lt;a href="https://en.wikipedia.org/wiki/Markov_chain_Monte_Carlo" target="_blank" rel="noopener">Markov Chain Monte Carlo&lt;/a> approach and &amp;ldquo;walk&amp;rdquo; through the brain. Directions where the diffusion signal is high will be sampled more often, and we can generate a robust representation of the macroscale neuronal structure in the brain using these random walks.&lt;/p>
&lt;figure id="figure-orientation-distribution-functions-from-vega-et-al-2009">
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/ODFs_hu_fdda87b1063ac2f3.jpeg" data-caption="Orientation distribution functions from Vega et al. 2009.">
&lt;img data-src="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/ODFs_hu_fdda87b1063ac2f3.jpeg" class="lazyload" alt="" width="838" height="556">
&lt;/a>
&lt;figcaption>
Orientation distribution functions from Vega et al. 2009.
&lt;/figcaption>
&lt;/figure>
&lt;p>The diffusion signal at the gray matter / white matter interface of the cortex is more isotropic than within the white matter (e.g. the diffusion tensors in these regions are more spherical). To reduce noise in my fiber tracking results due to this low signal, &lt;strong>I wanted to be able to force the first steps of the streamline propagation algorithm to follow a specific direction into the white matter, before beginning the MCMC sampling procedure&lt;/strong>. Essentially what this boils down to is providing &lt;code>probtrackx2&lt;/code> with prespecified spherical coordinates (azimuthal and polar angles) for the first propagation step. More specifically, I computed the initial spherical coordinates using surfaces computed from the mesh curvature flow results of
&lt;a href="https://www.sciencedirect.com/science/article/pii/S1053811917310583" target="_blank" rel="noopener">St.-Onge et al.&lt;/a> Importantly, I wanted to make use of the &lt;code>probtrackx2&lt;/code> infrastructure as much as possible e.g. I didn&amp;rsquo;t want to write my own classes for loading in surface data, and wanted to minimally update the members of any other classes I found useful.&lt;/p>
&lt;figure id="figure-surface-flow-seeded-tractography-from-st-onge-et-al-2018">
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/StOngeSurfaceFlow_hu_1d6fd555aa09d468.png" data-caption="Surface-flow seeded tractography from St-Onge et al. 2018.">
&lt;img data-src="https://kristianeschenburg.netlify.app/post/headfirst-into-uncommented-c&amp;#43;&amp;#43;/StOngeSurfaceFlow_hu_1d6fd555aa09d468.png" class="lazyload" alt="" width="821" height="237">
&lt;/a>
&lt;figcaption>
Surface-flow seeded tractography from St-Onge et al. 2018.
&lt;/figcaption>
&lt;/figure>
&lt;p>Jumping under the hood into the &lt;code>probtrackx2&lt;/code> code was a &lt;strong>feat&lt;/strong>. While the software is sophisticated, it is &lt;em>quite&lt;/em> poorly documented. As is common with academic code, development generally begins as a way to solve a specific problem in the lab, rather than as a package to be made available for public use. FSL has been around for a while, and grows in complexity all the time, so the initial academic-oriented mindset has somewhat propagated through their development cycles. I was able to identify the important classes and make my modifications to these three classes:&lt;/p>
&lt;ul>
&lt;li>
&lt;p>&lt;code>Particle&lt;/code> in particle.h :&lt;/p>
&lt;ul>
&lt;li>performs the tracking for a single streamline for a single seed, where MCMC sampling happens&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;code>Seedmanager&lt;/code> in streamlines.h :&lt;/p>
&lt;ul>
&lt;li>manages the individual seeds, instantiates &lt;code>Particle&lt;/code> objects&lt;/li>
&lt;/ul>
&lt;/li>
&lt;li>
&lt;p>&lt;code>Counter&lt;/code> in streamlines.h :&lt;/p>
&lt;ul>
&lt;li>keeps track of streamline coordinates in 3D-space, successful streamlines, binary brain masks, saves fiber count distributions as brain volumes&lt;/li>
&lt;/ul>
&lt;/li>
&lt;/ul>
&lt;p>The bulk of the tracking is done using these three &amp;ndash; the rest of the &lt;code>probtrackx2&lt;/code> code is almost entirely devoted to parsing other options and handling other input data. While I now have a lot of work to do in actually &lt;em>using&lt;/em> my modifications, this foray into FSL&amp;rsquo;s source code re-emphasized three important lessons:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>Documentation is &lt;strong>critical&lt;/strong>. But not just any documentation &amp;ndash; &lt;strong>meaningful&lt;/strong> documentation. Even if you aren&amp;rsquo;t the best at object-oriented software development, at least describe what your code does, and give your variables meaningful names. Had their code been effectively documented, I could have been in and out of there in two or three days, but instead spent about a week figuring out what was actually going on.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>You should be equally comfortable working with raw code developed by others, as you are with writing your own. Do not expect everything to be written correctly, and do not assume that just because others have used a piece of software before, that you won&amp;rsquo;t need to make modifications. Be ready to get your hands dirty.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>Do not underestimate the power of compiled languages. Most data scientists work with Python and R due to the speed of development and low barrier to entry, but each is based primarily in C (and I believe not in C++ due to timing of original development cycles). Many large-scale software packages are based on languages like C, C++, and Java. Likewise, if your work bridges the gap between data scientist and engineer, you&amp;rsquo;ll definitely need to be comfortable working with compiled languages for production-level development and deployment.&lt;/p>
&lt;/li>
&lt;/ol></description></item><item><title>Image Transformations With OpenCV</title><link>https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/</link><pubDate>Sat, 01 Sep 2018 17:12:32 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/</guid><description>&lt;p>I&amp;rsquo;ve been toying around with
&lt;a href="https://opencv.org/" target="_blank" rel="noopener">openCV&lt;/a> for generating MRI images with synethetic motion injected into them. I&amp;rsquo;d never used this library before, so I tested a couple examples. Below I detail a few tools that I found interesting, and that can quickly be used to generate image transformations.&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># import necessary libraries&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">matplotlib.pyplot&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">plt&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">nibabel&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="nn">nb&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">cv2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># load image file&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">image_file&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;./data/T1w_restore_brain.nii.gz&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">img_obj&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">nb&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">load&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">image_file&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">img&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">img_obj&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">get_data&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># reorient so Anterior-Posterior axis corresponds to dim(0)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">img&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">fliplr&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">img&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">img&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">swapaxes&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">img&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># get single image slice and rescale&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">img&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">130&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">:,&lt;/span> &lt;span class="p">:]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">data&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">min&lt;/span>&lt;span class="p">())&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">max&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">imshow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
&lt;figure >
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/original_hu_217e26846815c09b.jpg" >
&lt;img data-src="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/original_hu_217e26846815c09b.jpg" class="lazyload" alt="" width="432" height="432">
&lt;/a>
&lt;/figure>
&lt;p>For any linear transformations with &lt;code>cv2&lt;/code>, we can use the &lt;code>cv2.warpAffine&lt;/code> method, which takes in the original image, some transformation matrix, and the size of the output image.&lt;/p>
&lt;p>Let&amp;rsquo;s start with translations. The matrix will translate the image 10 pixels to the right (width), and 0 pixels down (height).&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Use the identity rotation matrix&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># Third column specifies translation in corresponding direction&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">translation&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">array&lt;/span>&lt;span class="p">([[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">20&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">0&lt;/span>&lt;span class="p">]])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">translated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warpAffine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">translation&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">imshow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">translated&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
&lt;figure >
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/translated_hu_2a216d917bfa49a0.jpg" >
&lt;img data-src="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/translated_hu_2a216d917bfa49a0.jpg" class="lazyload" alt="" width="432" height="432">
&lt;/a>
&lt;/figure>
&lt;p>Now, in order to rotate the image, we can use &lt;code>cv2.getRotationMatrix2D&lt;/code>. We&amp;rsquo;ll rotate our image by 45$^{\circ}$ .&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># get shape of input image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">rows&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cols&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># specify angle of rotation around central pixel&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">M&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getRotationMatrix2D&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="n">cols&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">rows&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="mi">45&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">rotated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warpAffine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">M&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">cols&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rows&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">imshow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rotated&lt;/span>&lt;span class="p">)&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>
&lt;figure >
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/rotated_hu_34be7ee45860ab04.jpg" >
&lt;img data-src="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/rotated_hu_34be7ee45860ab04.jpg" class="lazyload" alt="" width="432" height="432">
&lt;/a>
&lt;/figure>
&lt;p>Here are a few examples of randomly translating +/- 1, 5, or 9 voxels in the X and Y directions, and randomly rotating by 1, 5, or 9 degrees:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># get shape of input image&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">rows&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cols&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># specify range of rotations and translations&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">txfn&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">5&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">9&lt;/span>&lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">for&lt;/span> &lt;span class="n">rt&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">txfn&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># generate rotation matrix&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># randonly rotate to left or right&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">M&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">getRotationMatrix2D&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">(&lt;/span>&lt;span class="n">cols&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">rows&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">choice&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">rt&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># apply rotation matrix&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">rotated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warpAffine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">M&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># generate translation matrix&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># randomly translate to left or right&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">T&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">array&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">[[&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">choice&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">rt&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">random&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">choice&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="mi">1&lt;/span>&lt;span class="p">)[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span>&lt;span class="o">*&lt;/span>&lt;span class="n">rt&lt;/span>&lt;span class="p">]])&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">astype&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">float32&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># apply translation matrix&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">translated&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">cv2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">warpAffine&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">data&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">T&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">T&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># compose rotated and translated images&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">movement&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">rotated&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="n">translated&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">/&lt;/span>&lt;span class="mi">2&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># compute difference between input and transformed&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">difference&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">data&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">movement&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">res&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">difference&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">reshape&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">np&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">product&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">difference&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">shape&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">fig&lt;/span>&lt;span class="p">,[&lt;/span>&lt;span class="n">ax1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">ax2&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">ax3&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">subplots&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">3&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">figsize&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="mi">5&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax1&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">imshow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">movement&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cmap&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;gray&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax1&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_title&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Composed Random Rotation and Translation &lt;/span>&lt;span class="se">\n&lt;/span>&lt;span class="s1"> Magnitude = &lt;/span>&lt;span class="si">{:}&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rt&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">fontsize&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">imshow&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">D&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">rotated&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">cmap&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;gray&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax2&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_title&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Difference Map&amp;#39;&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">format&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">rt&lt;/span>&lt;span class="p">),&lt;/span> &lt;span class="n">fontsize&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax3&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">hist&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">res&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="n">res&lt;/span>&lt;span class="o">!=&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">],&lt;/span>&lt;span class="mi">100&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="n">density&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ax3&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">set_title&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;Difference Density&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">fontsize&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">15&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">tight_layout&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">plt&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">show&lt;/span>&lt;span class="p">()&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>
&lt;figure >
&lt;a data-fancybox="" href="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/Composed.1_hu_1f304674ba06f616.jpg" >
&lt;img data-src="https://kristianeschenburg.netlify.app/post/image-transformations-with-opencv/Composed.1_hu_1f304674ba06f616.jpg" class="lazyload" alt="" width="1080" height="360">
&lt;/a>
&lt;/figure>
&lt;figure >
&lt;a data-fancybox="" href="Composed.5.jpg" >
&lt;img src="Composed.5.jpg" alt="" >
&lt;/a>
&lt;/figure>
&lt;figure >
&lt;a data-fancybox="" href="Composed.9.jpg" >
&lt;img src="Composed.9.jpg" alt="" >
&lt;/a>
&lt;/figure>
&lt;/p>
&lt;p>While this approach of generating synthetic motion into MRI images is a poor model of how motion actually occurs during an MRI scan, there are a few things I learned here. For example, if you define a measure of image similarity, like mutual information, entropy, or correlation ratio as a cost function, we can see how we can use &lt;code>warpAffine&lt;/code> to find the optimal transformation matrix between two images.&lt;/p>
&lt;p>I was hoping to use openCV to generate and apply 3d affine transformations to volumetric MRI data. One approach to doing this is to iteratively apply rotations and transformations along each axis &amp;ndash; however, openCV will interpolate the data after each transformation, resulting in a greater loss of signal than I am willing to compromise on. It doesn&amp;rsquo;t seem like openCV has ability to apply 3d affine transformations to volumetric data in a single interpolation step.&lt;/p>
&lt;p>A more realistic approach to generating synthetic motion artifacts that would more accurately parallell the noise-generating process, is to compute the
&lt;a href="https://en.wikipedia.org/wiki/Fast_Fourier_transform" target="_blank" rel="noopener">Fast Fourier Transform&lt;/a> of my 3d volume, and then apply phase-shifts to the
&lt;a href="https://en.wikipedia.org/wiki/K-space_%28magnetic_resonance_imaging%29" target="_blank" rel="noopener">k-space&lt;/a> signal &amp;ndash; this will also manifest as motion after applying the inverse FFT.&lt;/p>
&lt;p>After doing a bit more digging through the openCV API, it seems there&amp;rsquo;s a lot of cool material for exploration &amp;ndash; these applications specifically caught my eye and would be fun to include in projects:&lt;/p>
&lt;ul>
&lt;li>
&lt;a href="https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_video/py_table_of_contents_video/py_table_of_contents_video.html#py-table-of-content-video" target="_blank" rel="noopener">video analysis&lt;/a> for motion tracking&lt;/li>
&lt;li>
&lt;a href="https://docs.opencv.org/3.0-beta/doc/py_tutorials/py_objdetect/py_face_detection/py_face_detection.html#face-detection" target="_blank" rel="noopener">object recognition&lt;/a> for detecting faces&lt;/li>
&lt;li>
&lt;a href="https://opencv.org/platforms/android/" target="_blank" rel="noopener">openCV Android&lt;/a> for app development&lt;/li>
&lt;/ul>
&lt;p>But alas &amp;ndash; the search continues!&lt;/p></description></item><item><title>Enabling Custom Jekyll Plugins with TravisCI</title><link>https://kristianeschenburg.netlify.app/post/enabling-custom-jekyll-plugins/</link><pubDate>Sun, 12 Aug 2018 02:14:14 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/enabling-custom-jekyll-plugins/</guid><description>&lt;p>I just learned about
&lt;a href="https://travis-ci.org/" target="_blank" rel="noopener">TravisCI&lt;/a> (actually, about continuous integration (CI) in general) after attending
&lt;a href="http://neurohackademy.org/" target="_blank" rel="noopener">Neurohackademy 2018&lt;/a>. We learned about CI from the perspective of ensuring that your code builds properly when you update files in your packages, incorporate new methods, refactor your code, etc. Pretty neat.&lt;/p>
&lt;p>Fast forward a couple days, and I&amp;rsquo;m trying to incorporate custom Jekyll plugins into my blog &amp;ndash; I quickly realized GitHub doesn&amp;rsquo;t allow this for security reasons, but I couldn&amp;rsquo;t find a convenient work-around. Some posts suggested using a separate repo branch to build the site, and then push the static HTML files up to a remote repo to do the actual hosting, but for some reason I couldn&amp;rsquo;t get that approach to work.&lt;/p>
&lt;p>Finally, I saw some mentions of using TravisCI and
&lt;a href="https://circleci.com/pricing/?utm_source=gb&amp;amp;utm_medium=SEM&amp;amp;utm_campaign=SEM-gb-200-Eng-ni&amp;amp;utm_content=SEM-gb-200-Eng-ni-Circle-CI&amp;amp;gclid=Cj0KCQjwtb_bBRCFARIsAO5fVvGQIO23w0ahWrTj3v8MrGLEnjI00KcEClqUuQda-Q_cz05h8jjEC5QaAjeREALw_wcB" target="_blank" rel="noopener">CircleCI&lt;/a> to build and push the site using continuous integration. I ended up using the approach suggested by
&lt;a href="http://joshfrankel.me/blog/deploying-a-jekyll-blog-to-github-pages-with-custom-plugins-and-travisci/" target="_blank" rel="noopener">Josh Frankel&lt;/a>.&lt;/p>
&lt;p>Josh&amp;rsquo;s site gives a really clear explanation of the necessary steps, given some very minmal prequisite knowledge about using Git. His instructions actually worked almost perfectly for me, so I won&amp;rsquo;t repeat them again here (just follow the link above, if you&amp;rsquo;re interested) &amp;ndash; however, there were a few issues that arose on my end:&lt;/p>
&lt;ol>
&lt;li>
&lt;p>For some reason, I had an &lt;code>about.html&lt;/code> file and &lt;code>index.html&lt;/code> file in the main repo directory &amp;ndash; my built blog wouldn&amp;rsquo;t register any updates I made to &lt;code>about.md&lt;/code> or &lt;code>index.md&lt;/code> while these files were around, so I deleted the HTML files. This might have been an obvious bug to someone with more web programming experience, but I&amp;rsquo;m a novice at that. If you&amp;rsquo;re seeing any wonky behavior, check to make sure you don&amp;rsquo;t have any unnecessary files hanging around.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Ruby version&lt;/strong>: I had to change the version of Ruby I was using to &lt;code>ruby-2.4.1&lt;/code>.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Plugins&lt;/strong>: Make sure any Jekyll plugins you want to use are already installed.&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Emails&lt;/strong>: You can turn off email reporting from TravisCI by adding
&lt;code>notifications: email: false&lt;/code> to your &lt;code>.travis.yml&lt;/code> file.&lt;/p>
&lt;/li>
&lt;/ol>
&lt;p>But now, you can incorporate custom, user-built Jekyll plugins and let TravisCI do the heavy lifting! I specifically wanted the ability to reference papers using BibTex-style citation links with Jekyll, like you can with LaTex or Endnote &amp;ndash; this capability isn&amp;rsquo;t currently supported by GitHub. Happy blogging!&lt;/p></description></item><item><title>Rendering LaTex In Markdown Using Jekyll</title><link>https://kristianeschenburg.netlify.app/post/rendering-latex-in-markdown-using-jekyll/</link><pubDate>Sat, 11 Aug 2018 02:14:14 -0700</pubDate><guid>https://kristianeschenburg.netlify.app/post/rendering-latex-in-markdown-using-jekyll/</guid><description>&lt;p>In putting together this blog, I wanted to be able to talk about various mathematical topics that I found interesting, which inevitably lead to using LaTex in my posts.&lt;/p>
&lt;p>I&amp;rsquo;m currently using Atom as my editor (having converted from Sublime), and needed to install a bunch of packages first. First and foremost, I wanted to be able to render my markdown posts before hosting them on the blog, and consequentially needed a way to render LaTex. For this, I installed a few Atom packages:&lt;/p>
&lt;ul>
&lt;li>
&lt;a href="https://atom.io/packages/markdown-it-preview" target="_blank" rel="noopener">Markdown-Preview&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://atom.io/packages/latex" target="_blank" rel="noopener">Latex&lt;/a>&lt;/li>
&lt;li>
&lt;a href="https://atom.io/packages/language-latex" target="_blank" rel="noopener">Language-Latex&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>To preview your post in Atom, you just type &lt;code>ctrl+shift+M&lt;/code>, which will display both in-line and block math sections.&lt;/p>
&lt;p>However, if you build your site locally with the command &lt;code>bundle exec jekyll serve&lt;/code> or push it to a remote repo, the LaTex no longer renders properly. After Googling around a bit, I determined that this was due to the way markdown converters in Jekyll, like &lt;strong>kramdown&lt;/strong> and &lt;strong>redcarpet&lt;/strong>, do the conversion using MathJax &amp;ndash; specifically, in-line math segments are not properly rendered. I wanted a way to both preview the LaTex in Atom, and properly render it usng Jekyll. I found two links that solved the problem for me:&lt;/p>
&lt;ul>
&lt;li>
&lt;a href="http://www.gastonsanchez.com/visually-enforced/opinion/2014/02/16/Mathjax-with-jekyll/" target="_blank" rel="noopener">Visually Enforced&lt;/a>&lt;/li>
&lt;li>
&lt;a href="http://www.iangoodfellow.com/blog/jekyll/markdown/tex/2016/11/07/latex-in-markdown.html" target="_blank" rel="noopener">LaTeX in Jekyll&lt;/a>&lt;/li>
&lt;/ul>
&lt;p>In short, the following steps solved the problem of LaTex not rendering for me. I&amp;rsquo;m using the &lt;strong>minima&lt;/strong> theme, so I first found the theme directory with &lt;code>bundle show minima&lt;/code>. In this directory, I copied the &lt;strong>./layouts/post.html&lt;/strong> to a local directory in my project folder called &lt;strong>./_layouts/post.html&lt;/strong>.&lt;/p>
&lt;p>Within this file, I pasted the following two sections of HTML code:&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">script&lt;/span> &lt;span class="na">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;text/x-mathjax-config&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">MathJax&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Hub&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">Config&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">tex2jax&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">skipTags&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;script&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;noscript&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;style&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;textarea&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">inlineMath&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[[&lt;/span>&lt;span class="s1">&amp;#39;$&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>&lt;span class="s1">&amp;#39;$&amp;#39;&lt;/span>&lt;span class="p">]]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">script&lt;/span> &lt;span class="na">src&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;https://cdn.mathjax.org/mathjax/latest/MathJax.js?config=TeX-AMS-MML_HTMLorMML&amp;#34;&lt;/span> &lt;span class="na">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;text/javascript&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&amp;lt;/&lt;/span>&lt;span class="nt">script&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>And voila &amp;ndash; building the posts now correctly renders LaTex!&lt;/p></description></item></channel></rss>