<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Gcp on inetshell</title><link>https://inet.sh/en/tags/gcp/</link><description>Recent content in Gcp on inetshell</description><generator>Hugo</generator><language>en</language><lastBuildDate>Sat, 26 Sep 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://inet.sh/en/tags/gcp/index.xml" rel="self" type="application/rss+xml"/><item><title>GCP Cloud VPN will not come up: reading the IKE logs (peer not responding, traffic selectors narrowed) and BGP with pfSense</title><link>https://inet.sh/en/posts/gcp/cloud-vpn-not-up-ike-logs/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/gcp/cloud-vpn-not-up-ike-logs/</guid><description>When a Cloud VPN tunnel sits on &amp;#39;No incoming packets&amp;#39; or flaps, the answer is in Cloud Logging: the queries per phase (IKE_SA, CHILD_SA, rekey, DELETE) and what each message means: peer not responding, traffic selectors narrowed, received DELETE. Plus the HA VPN with BGP against pfSense/FRR case, where no routes come in until you add route maps.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p>In <strong>Logs Explorer</strong>, every tunnel phase has its query:</p>
<table>
  <thead>
      <tr>
          <th>What you are after</th>
          <th>Query</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Cloud VPN cannot reach the peer</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; &quot;establishing IKE_SA failed, peer not responding&quot;</code></td>
      </tr>
      <tr>
          <td>Phase 1 starts</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;initiating IKE_SA&quot; OR &quot;generating IKE_SA_INIT request&quot;)</code></td>
      </tr>
      <tr>
          <td>IKE authentication</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;generating IKE_AUTH request&quot; OR &quot;parsed IKE_AUTH response&quot;)</code></td>
      </tr>
      <tr>
          <td>PSK accepted</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;authentication of&quot; AND &quot;with pre-shared key successful&quot;)</code></td>
      </tr>
      <tr>
          <td>Phase 1 established</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;IKE_SA&quot; AND &quot;established between&quot;)</code></td>
      </tr>
      <tr>
          <td>Everything phase 2 (rekeys included)</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; &quot;CHILD_SA&quot;</code></td>
      </tr>
      <tr>
          <td>Subnets do not match</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;Remote traffic selectors narrowed&quot; OR &quot;Local traffic selectors narrowed&quot;)</code></td>
      </tr>
      <tr>
          <td>Someone closes phase 2</td>
          <td><code>resource.type=&quot;vpn_gateway&quot; (&quot;received DELETE for ESP CHILD_SA&quot; OR &quot;sending DELETE for ESP CHILD_SA&quot;)</code></td>
      </tr>
  </tbody>
</table>
<p>Cloud VPN runs strongSwan underneath, so the messages are strongSwan&rsquo;s: if you know <code>charon</code>, you already know how to read them.</p>
<h2 id="what-each-message-means">What each message means</h2>
<p><strong><code>establishing IKE_SA failed, peer not responding</code>.</strong> Cloud VPN sends IKE_SA_INIT and nobody answers. In order of likelihood: the peer is on IKEv1 and the tunnel on IKEv2 (or the reverse), the configured peer IP is not the real public one (NAT in front of the firewall), UDP 500 and 4500 do not reach the peer, or the peer does not even have the tunnel enabled. The PSK does <strong>not</strong> produce this message: with a wrong PSK, phase 1 starts and fails at <code>IKE_AUTH</code>.</p>
<p><strong><code>IKE_SA … established between</code> and then nothing.</strong> Phase 1 fine, phase 2 not. Look for <code>CHILD_SA</code> and <code>traffic selectors</code>.</p>
<p><strong><code>Remote traffic selectors narrowed</code> / <code>Local traffic selectors narrowed</code>.</strong> The phase 2 networks do not match on both sides. On HA VPN (and route-based Classic VPN) Cloud VPN proposes <strong><code>0.0.0.0/0</code> on both sides</strong> and expects the same; a <em>policy-based</em> peer with specific subnets makes strongSwan narrow the selectors and pass traffic only between what matched, or nothing. On the peer: phase 2 with <code>0.0.0.0/0</code> local and remote (on pfSense, <em>Routed</em> mode, which creates an <code>ipsecN</code> tunnel interface), and routing handled by BGP or static routes.</p>
<p><strong><code>received DELETE for ESP CHILD_SA</code> in a loop.</strong> The peer tears phase 2 down every few minutes: mismatched phase 2 lifetimes with badly negotiated rekeys, or aggressive DPD. Match lifetimes (Cloud VPN uses 3 hours for phase 2 and 10 for phase 1) and let only one side initiate rekeys.</p>
<p><strong>Comes up, passes traffic for a while, dies with NAT-T.</strong> A peer behind NAT on UDP 4500 and a middlebox that &ldquo;helps&rdquo; (IPsec ALG on the router) drops encapsulated ESP packets. Disable the ALG or move the firewall onto a direct public IP.</p>
<h2 id="ha-vpn-with-bgp-against-pfsense-frr">HA VPN with BGP against pfSense (FRR)</h2>
<p>The tunnel comes up, <code>bgp</code> establishes, and <strong>no route comes in or goes out</strong>. FRR on pfSense starts with a policy that rejects everything not explicitly permitted:</p>
<ol>
<li><strong>Services → FRR → BGP → Route Maps</strong>: create <code>ALLOW-ALL</code>, <em>Action</em> Permit, <em>Sequence</em> 100, no criteria.</li>
<li><strong>BGP → Neighbors</strong> → the Cloud Router peer: <em>Remote AS</em> the Cloud Router&rsquo;s ASN, and under <em>Peer Filtering</em> assign <code>ALLOW-ALL</code> as route map <strong>in</strong> and <strong>out</strong>.</li>
<li><strong>BGP → Network Distribution</strong>: list the local networks you want to advertise (or <em>Default Originate</em> if everything goes through the tunnel).</li>
<li>On GCP, the Cloud Router with <code>--advertisement-mode custom</code> if you want to control which prefixes go out; by default it advertises the VPC subnets.</li>
</ol>
<p>Without step 2, <code>vtysh -c &quot;show ip bgp summary&quot;</code> shows the session <em>Established</em> with 0 prefixes in both directions, which is exactly the symptom.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>IKEv2</strong> unless the peer cannot: IKEv1 is still supported, but without IPv6 and with fewer ciphers. Classic VPN still exists (99.9%), but part of its functionality is retired; new work goes on HA VPN (99.99%).</li>
<li><strong>Two tunnels per gateway</strong> on HA VPN for the 99.99%: if you only bring one up, the gateway status stays in warning even though it works.</li>
<li><strong>MTU</strong>: 1460 by default on the GCP VPC; with IPsec on top, let TCP do MSS clamping on the peer (pfSense does it on the tunnel&rsquo;s <code>ipsecN</code> interface) or you will see transfers hanging after the handshake.</li>
<li><strong>Logs lag</strong>: a minute or two between the event and Logs Explorer. Refresh before changing the configuration again.</li>
<li><strong>A PSK with special characters</strong> copied from a password manager: check on the peer that nothing got eaten; the symptom is <code>IKE_AUTH</code> failing, not <em>peer not responding</em>.</li>
</ul>
<h2 id="see-also">See also</h2>
<ul>
<li><a href="/en/posts/pfsense/nat-reflection-port-forward-from-lan/">pfSense: NAT reflection and split DNS</a> — the other classic of &ldquo;the tunnel is fine and I still cannot get there&rdquo;.</li>
<li><a href="https://docs.cloud.google.com/network-connectivity/docs/vpn/support/troubleshooting">Cloud VPN troubleshooting</a> in the Google Cloud documentation.</li>
</ul>
]]></content:encoded></item><item><title>GitHub Actions → Google Cloud without keys: Workload Identity Federation with gcloud and Terraform</title><link>https://inet.sh/en/posts/gcp/github-actions-workload-identity-federation/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/gcp/github-actions-workload-identity-federation/</guid><description>Authenticate a GitHub Actions workflow to Google Cloud without storing a service account JSON key: Workload Identity Federation pool and OIDC provider, an attribute condition that limits it to your org or repo, the workloadIdentityUser binding, and the google-github-actions/auth@v3 step with permissions id-token: write. The same setup in Terraform with the gh-oidc module, and the usual permission errors.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">PROJECT_ID</span><span class="o">=</span>my-project
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">PROJECT_NUMBER</span><span class="o">=</span><span class="k">$(</span>gcloud projects describe <span class="nv">$PROJECT_ID</span> --format<span class="o">=</span><span class="s1">&#39;value(projectNumber)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">GITHUB_ORG</span><span class="o">=</span>my-org
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REPO</span><span class="o">=</span>my-org/my-repo
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">gcloud iam workload-identity-pools create github <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --project<span class="o">=</span><span class="nv">$PROJECT_ID</span> --location<span class="o">=</span>global --display-name<span class="o">=</span><span class="s2">&#34;GitHub Actions&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">gcloud iam workload-identity-pools providers create-oidc github-oidc <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --project<span class="o">=</span><span class="nv">$PROJECT_ID</span> --location<span class="o">=</span>global --workload-identity-pool<span class="o">=</span>github <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --display-name<span class="o">=</span><span class="s2">&#34;GitHub OIDC&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --issuer-uri<span class="o">=</span><span class="s2">&#34;https://token.actions.githubusercontent.com&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --attribute-mapping<span class="o">=</span><span class="s2">&#34;google.subject=assertion.sub,attribute.actor=assertion.actor,attribute.repository=assertion.repository,attribute.repository_owner=assertion.repository_owner&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --attribute-condition<span class="o">=</span><span class="s2">&#34;assertion.repository_owner == &#39;</span><span class="nv">$GITHUB_ORG</span><span class="s2">&#39;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">gcloud iam service-accounts create github-actions --project<span class="o">=</span><span class="nv">$PROJECT_ID</span> --display-name<span class="o">=</span><span class="s2">&#34;GitHub Actions&#34;</span>
</span></span><span class="line"><span class="cl">gcloud iam service-accounts add-iam-policy-binding github-actions@<span class="nv">$PROJECT_ID</span>.iam.gserviceaccount.com <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --project<span class="o">=</span><span class="nv">$PROJECT_ID</span> --role<span class="o">=</span>roles/iam.workloadIdentityUser <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --member<span class="o">=</span><span class="s2">&#34;principalSet://iam.googleapis.com/projects/</span><span class="nv">$PROJECT_NUMBER</span><span class="s2">/locations/global/workloadIdentityPools/github/attribute.repository/</span><span class="nv">$REPO</span><span class="s2">&#34;</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>And in the workflow:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write           </span><span class="w"> </span><span class="c"># without this GitHub does not issue the OIDC token</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">auth</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">google-github-actions/auth@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">workload_identity_provider</span><span class="p">:</span><span class="w"> </span><span class="l">projects/123456789012/locations/global/workloadIdentityPools/github/providers/github-oidc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">service_account</span><span class="p">:</span><span class="w"> </span><span class="l">github-actions@my-project.iam.gserviceaccount.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">google-github-actions/setup-gcloud@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">gcloud storage ls</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Whatever roles the pipeline needs (Artifact Registry, Cloud Run, GKE…) go to <code>github-actions@…</code> like to any service account.</p>
<h2 id="why">Why</h2>
<p>The classic alternative is exporting a service account JSON key and storing it as a GitHub secret. That key never expires, anyone with access to the repo or the logs can copy it, and rotating it is manual work nobody does. With <strong>Workload Identity Federation</strong> GitHub issues a short-lived OIDC token for each job, Google verifies it against GitHub&rsquo;s issuer, checks your <em>attribute condition</em>, and hands out temporary service account credentials. There is no secret to store or rotate, and you can restrict who gets in down to the repo or branch.</p>
<h2 id="the-pieces">The pieces</h2>
<table>
  <thead>
      <tr>
          <th>Piece</th>
          <th>What it is</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Pool</strong> (<code>github</code>)</td>
          <td>Container for external identities; one per provider is enough</td>
      </tr>
      <tr>
          <td><strong>OIDC provider</strong> (<code>github-oidc</code>)</td>
          <td>Trusts <code>https://token.actions.githubusercontent.com</code> and maps token claims (<code>repository</code>, <code>repository_owner</code>, <code>actor</code>, <code>ref</code>) to attributes</td>
      </tr>
      <tr>
          <td><strong>Attribute condition</strong></td>
          <td>CEL filter over those claims. Without it, <strong>any GitHub repo</strong> could try to enter the pool; the official README says to always set one</td>
      </tr>
      <tr>
          <td><strong><code>workloadIdentityUser</code> binding</strong></td>
          <td>Which pool identities may impersonate the service account: <code>attribute.repository/my-org/my-repo</code>, or <code>attribute.repository_owner/my-org</code> for the whole organization</td>
      </tr>
      <tr>
          <td><strong><code>auth@v3</code></strong></td>
          <td>Exchanges GitHub&rsquo;s token for Google credentials and leaves them configured for <code>gcloud</code>, the SDKs, and Terraform</td>
      </tr>
  </tbody>
</table>
<p>To restrict to a branch, map the claim and use it in both the condition and the binding:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">--attribute-mapping=&#34;…,attribute.ref=assertion.ref&#34;
</span></span><span class="line"><span class="cl">--attribute-condition=&#34;assertion.repository_owner == &#39;my-org&#39; &amp;&amp; assertion.ref == &#39;refs/heads/main&#39;&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="with-terraform">With Terraform</h2>
<p>The official module creates pool, provider, and bindings in one block:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span><span class="lnt">12
</span><span class="lnt">13
</span><span class="lnt">14
</span><span class="lnt">15
</span><span class="lnt">16
</span><span class="lnt">17
</span><span class="lnt">18
</span><span class="lnt">19
</span><span class="lnt">20
</span><span class="lnt">21
</span><span class="lnt">22
</span><span class="lnt">23
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">module</span> <span class="s2">&#34;github_actions_sa&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  source</span>     <span class="o">=</span> <span class="s2">&#34;terraform-google-modules/service-accounts/google&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  version</span>    <span class="o">=</span> <span class="s2">&#34;~&gt; 4.0&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  project_id</span> <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">project_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  names</span>      <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;github-actions&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  project_roles</span> <span class="o">=</span><span class="n"> [&#34;${var.project_id}</span><span class="o">=</span><span class="err">&gt;</span><span class="k">roles</span><span class="err">/</span><span class="k">artifactregistry</span><span class="p">.</span><span class="k">writer</span><span class="err">&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">module</span> <span class="s2">&#34;gh_oidc&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  source</span>              <span class="o">=</span> <span class="s2">&#34;terraform-google-modules/github-actions-runners/google//modules/gh-oidc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  project_id</span>          <span class="o">=</span> <span class="k">var</span><span class="p">.</span><span class="k">project_id</span>
</span></span><span class="line"><span class="cl"><span class="n">  pool_id</span>             <span class="o">=</span> <span class="s2">&#34;github&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  provider_id</span>         <span class="o">=</span> <span class="s2">&#34;github-oidc&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  attribute_condition</span> <span class="o">=</span><span class="n"> &#34;assertion.repository_owner</span> <span class="o">==</span> <span class="err">&#39;</span><span class="k">my</span><span class="err">-</span><span class="k">org</span><span class="err">&#39;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  sa_mapping</span> <span class="o">=</span> {
</span></span><span class="line"><span class="cl"><span class="n">    &#34;github-actions&#34;</span> <span class="o">=</span> {
</span></span><span class="line"><span class="cl"><span class="n">      sa_name</span>   <span class="o">=</span> <span class="s2">&#34;projects/${var.project_id}/serviceAccounts/${module.github_actions_sa.email}&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">      attribute</span> <span class="o">=</span> <span class="s2">&#34;attribute.repository/my-org/my-repo&#34;</span>
</span></span><span class="line"><span class="cl">    }
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">output &#34;workload_identity_provider&#34; { value</span> <span class="o">=</span> <span class="k">module</span><span class="p">.</span><span class="k">gh_oidc</span><span class="p">.</span><span class="k">provider_name</span> }
</span></span></code></pre></td></tr></table>
</div>
</div><p>The <code>output</code> is exactly the value that goes into the workflow&rsquo;s <code>workload_identity_provider</code>.</p>
<h2 id="without-a-service-account">Without a service account</h2>
<p><code>auth@v3</code> also supports <strong>direct federation</strong>: the <code>principalSet</code> gets IAM roles without going through a service account (<code>workload_identity_provider</code> and <code>project_id</code>, no <code>service_account</code>). Fewer pieces, but not every service accepts federated principals; Artifact Registry, GCS, and most APIs do, and it is the path Google recommends for new setups.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong><code>id-token: write</code></strong> in the job&rsquo;s <code>permissions</code>. Without it, <code>auth</code> fails with <em>&ldquo;Unable to get ACTIONS_ID_TOKEN_REQUEST_URL&rdquo;</em>.</li>
<li><strong>Project number, not ID</strong>, in the provider path and the <code>principalSet</code>: <code>projects/123456789012/…</code>.</li>
<li><strong><code>Permission 'iam.serviceAccounts.getAccessToken' denied</code></strong>: the <code>roles/iam.workloadIdentityUser</code> binding is missing or <code>attribute.repository</code> does not match (case and exact repo name).</li>
<li><strong>The condition must reference mapped claims</strong>: if you use <code>assertion.ref</code> in the condition without mapping it to an attribute, the provider gets created but never matches.</li>
<li><strong>Forks and external PRs</strong>: tokens from a fork&rsquo;s PR carry the fork&rsquo;s <code>repository</code>; a condition on <code>repository_owner</code> keeps them out, which is usually what you want.</li>
<li><strong>Rotation</strong>: nothing to rotate, but something to review. <code>gcloud iam workload-identity-pools providers describe</code> and the STS audit logs tell you who is coming in.</li>
</ul>
<p>Reference: <a href="https://github.com/google-github-actions/auth">google-github-actions/auth</a> and <a href="https://docs.cloud.google.com/iam/docs/workload-identity-federation">Workload Identity Federation</a> in the Google Cloud documentation.</p>
]]></content:encoded></item><item><title>Google-managed certificate that never gets issued: CAA (pki.goog, letsencrypt.org), FAILED_NOT_VISIBLE and the right order</title><link>https://inet.sh/en/posts/gcp/google-managed-certificate-caa/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/gcp/google-managed-certificate-caa/</guid><description>The Google-managed certificate for an HTTPS load balancer sits in PROVISIONING for hours or drops to FAILED_NOT_VISIBLE or FAILED_CAA_FORBIDDEN. The three causes: DNS does not point to the load balancer IP yet, the certificate is not attached to a target proxy, or a CAA record in the zone does not authorize pki.goog or letsencrypt.org. How to check the status with gcloud, the correct CAA, and Certificate Manager with DNS authorization to issue before switching DNS.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;pki.goog&#34;
</span></span><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;letsencrypt.org&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">gcloud compute ssl-certificates describe web-cert --global --format<span class="o">=</span><span class="s1">&#39;yaml(managed)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># managed:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   domainStatus:</span>
</span></span><span class="line"><span class="cl"><span class="c1">#     www.example.com: PROVISIONING | ACTIVE | FAILED_NOT_VISIBLE | FAILED_CAA_FORBIDDEN | FAILED_CAA_CHECKING | FAILED_RATE_LIMITED</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   status: PROVISIONING | ACTIVE</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p>To reach <code>ACTIVE</code> three things must hold at once: the domain&rsquo;s <strong>A/AAAA</strong> points to the load balancer IP (and nothing else), the certificate is <strong>attached to a target proxy</strong> with its forwarding rule, and no <strong>CAA</strong> in the zone forbids Google&rsquo;s CAs. Then, up to <strong>60 minutes</strong>.</p>
<h2 id="why">Why</h2>
<p>Google issues managed certificates through <strong>Google Trust Services</strong> (<code>pki.goog</code>) or <strong>Let&rsquo;s Encrypt</strong>, and picks one or the other on each issuance or renewal. To validate the domain it runs an HTTP check against the IP the name resolves to, so until DNS points at the load balancer there is nothing to validate. And like any CA, it honors <strong>CAA</strong> records: if your zone says only <code>digicert.com</code> may issue (a typical corporate policy), Google cannot.</p>
<p>The statuses tell the story:</p>
<table>
  <thead>
      <tr>
          <th><code>domainStatus</code></th>
          <th>Cause</th>
          <th>What to do</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>PROVISIONING</code></td>
          <td>In progress</td>
          <td>Wait; past 60 minutes, check the items below</td>
      </tr>
      <tr>
          <td><code>FAILED_NOT_VISIBLE</code></td>
          <td>The name does not resolve to the load balancer IP, or the certificate is not attached to a target proxy</td>
          <td>Fix A/AAAA, attach the certificate, wait for propagation</td>
      </tr>
      <tr>
          <td><code>FAILED_CAA_CHECKING</code></td>
          <td>Google could not read the CAA records (unresponsive DNS, broken DNSSEC)</td>
          <td>Fix the zone</td>
      </tr>
      <tr>
          <td><code>FAILED_CAA_FORBIDDEN</code></td>
          <td>There is a CAA and it includes neither <code>pki.goog</code> nor <code>letsencrypt.org</code></td>
          <td>Add both records</td>
      </tr>
      <tr>
          <td><code>FAILED_RATE_LIMITED</code></td>
          <td>Too many issuance attempts</td>
          <td>Wait; do not recreate the certificate in a loop</td>
      </tr>
  </tbody>
</table>
<h2 id="the-right-caa">The right CAA</h2>
<p>With no CAA record at all, any CA may issue and Google works. If you have CAA (or your organization requires it), add <strong>both</strong> CAs:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;pki.goog&#34;
</span></span><span class="line"><span class="cl">example.com.   CAA 0 issue &#34;letsencrypt.org&#34;
</span></span></code></pre></td></tr></table>
</div>
</div><p>Listing only one forces Google to always use that one; the documentation advises against it because a renewal may come out through the other. CAA records inherit downwards: a CAA on <code>example.com</code> covers <code>www.example.com</code> if the subdomain has none of its own.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">dig +short CAA example.com
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="certificate-manager-issue-before-switching-dns">Certificate Manager: issue before switching DNS</h2>
<p>With classic certificates (<code>compute ssl-certificates</code>) the domain must already point at the load balancer, which during a migration means a few minutes of broken HTTPS. <strong>Certificate Manager</strong> solves that with <strong>DNS authorization</strong>: you publish a validation CNAME and Google issues the certificate while traffic still flows to the old site.</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">gcloud certificate-manager dns-authorizations create www-auth --domain<span class="o">=</span>www.example.com
</span></span><span class="line"><span class="cl">gcloud certificate-manager dns-authorizations describe www-auth --format<span class="o">=</span><span class="s1">&#39;yaml(dnsResourceRecord)&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># dnsResourceRecord: name: _acme-challenge.www.example.com. type: CNAME data: xxxx.authorize.certificatemanager.goog.</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># publish that CNAME in your zone and then:</span>
</span></span><span class="line"><span class="cl">gcloud certificate-manager certificates create www-cert <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --domains<span class="o">=</span>www.example.com --dns-authorizations<span class="o">=</span>www-auth
</span></span><span class="line"><span class="cl">gcloud certificate-manager maps create web-map
</span></span><span class="line"><span class="cl">gcloud certificate-manager maps entries create www --map<span class="o">=</span>web-map --certificate<span class="o">=</span>www-cert --hostname<span class="o">=</span>www.example.com
</span></span><span class="line"><span class="cl">gcloud compute target-https-proxies update web-proxy --certificate-map<span class="o">=</span>web-map
</span></span></code></pre></td></tr></table>
</div>
</div><p>Once the certificate is <code>ACTIVE</code>, you flip the A/AAAA and the cutover is zero. CAA applies the same way.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>Cloudflare or another proxy in front</strong> of the load balancer: the A record points at the proxy, not at Google, and validation fails with <code>FAILED_NOT_VISIBLE</code>. Grey cloud during issuance, or Certificate Manager with DNS authorization.</li>
<li><strong>A managed certificate attached to nothing never gets issued.</strong> Creating it and waiting does nothing; attach it to the target proxy first.</li>
<li><strong>Changing the domain list</strong> creates a new certificate: attach the new one, wait for <code>ACTIVE</code>, and only then remove the old one from the proxy, or you get a gap.</li>
<li><strong>Wildcards</strong> only with Certificate Manager and DNS authorization; classic managed certificates do not support them.</li>
<li><strong>Badly signed DNSSEC</strong> breaks CAA lookups: <code>FAILED_CAA_CHECKING</code> even when you have no CAA at all.</li>
</ul>
<p>Reference: <a href="https://docs.cloud.google.com/load-balancing/docs/ssl-certificates/google-managed-certs">Google-managed SSL certificates</a> and <a href="https://docs.cloud.google.com/certificate-manager/docs/overview">Certificate Manager</a> in the Google Cloud documentation.</p>
]]></content:encoded></item><item><title>Importing dumps into Cloud SQL without errors: DEFINER and SUPER (ERROR 1227) on MySQL, extensions and owners on PostgreSQL</title><link>https://inet.sh/en/posts/gcp/cloud-sql-import-dumps-definer-gtid-extensions/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/gcp/cloud-sql-import-dumps-definer-gtid-extensions/</guid><description>A mysqldump or pg_dump file that works on any server fails when imported into Cloud SQL: ERROR 1227 &amp;#39;you need the SUPER privilege&amp;#39; from DEFINER clauses and SET @@GLOBAL.GTID_PURGED, &amp;#39;must be owner of extension plpgsql&amp;#39; and &amp;#39;role does not exist&amp;#39; on PostgreSQL. The export flags Cloud SQL expects, what to do with DEFINER clauses (create the users, strip them, or switch to INVOKER), the sed that comments out EXTENSION statements, the bucket permission, and the import with gcloud sql import sql.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<p><strong>MySQL</strong>, on the source server:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">mysqldump --databases app -h db.example -u dumper -p <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --hex-blob --single-transaction --set-gtid-purged<span class="o">=</span>OFF <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --default-character-set<span class="o">=</span>utf8mb4 --routines --triggers --events <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="p">|</span> sed -E <span class="s1">&#39;s/DEFINER=`[^`]+`@`[^`]+`//g&#39;</span> <span class="p">|</span> gzip &gt; app.sql.gz
</span></span></code></pre></td></tr></table>
</div>
</div><p><strong>PostgreSQL</strong>, on the source server:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">pg_dump app -h db.example -U dumper --format<span class="o">=</span>plain --no-owner --no-acl <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  <span class="p">|</span> sed -E <span class="s1">&#39;s/(DROP|CREATE|COMMENT ON) EXTENSION/-- \1 EXTENSION/g&#39;</span> <span class="p">|</span> gzip &gt; app.sql.gz
</span></span></code></pre></td></tr></table>
</div>
</div><p>And in both cases:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nv">SA</span><span class="o">=</span><span class="k">$(</span>gcloud sql instances describe my-instance --format<span class="o">=</span><span class="s1">&#39;value(serviceAccountEmailAddress)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">gcloud storage buckets add-iam-policy-binding gs://my-dumps <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --member<span class="o">=</span><span class="s2">&#34;serviceAccount:</span><span class="nv">$SA</span><span class="s2">&#34;</span> --role<span class="o">=</span>roles/storage.objectAdmin
</span></span><span class="line"><span class="cl">gcloud storage cp app.sql.gz gs://my-dumps/
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">gcloud sql import sql my-instance gs://my-dumps/app.sql.gz --database<span class="o">=</span>app                 <span class="c1"># MySQL</span>
</span></span><span class="line"><span class="cl">gcloud sql import sql my-instance gs://my-dumps/app.sql.gz --database<span class="o">=</span>app --user<span class="o">=</span>postgres  <span class="c1"># PostgreSQL</span>
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>gcloud sql import sql</code> takes plain SQL or <code>.gz</code>: the <em>custom</em> format of <code>pg_dump</code> goes through <code>pg_restore</code> (below).</p>
<h2 id="the-errors-and-where-they-come-from">The errors and where they come from</h2>
<p>On Cloud SQL nobody has <code>SUPER</code> (MySQL) or is a superuser (PostgreSQL); the admin is <code>root@%</code> or <code>postgres</code>, a member of <code>cloudsqlsuperuser</code>. Everything a normal dump takes for granted on your own server breaks right there:</p>
<table>
  <thead>
      <tr>
          <th>Import error</th>
          <th>Engine</th>
          <th>Cause</th>
          <th>Fix</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>ERROR 1227 (42000) at line N: Access denied; you need (at least one of) the SUPER privilege(s) for this operation</code> (on a <code>CREATE … VIEW</code>, <code>TRIGGER</code>, <code>PROCEDURE</code>, <code>FUNCTION</code> or <code>EVENT</code> line)</td>
          <td>MySQL</td>
          <td><code>DEFINER=`user`@`host` </code> for a user that does not exist on the instance, or for <code>root@localhost</code>, which never exists on Cloud SQL. The dump does not carry users (and must not)</td>
          <td>Create those users before importing, or strip the <code>DEFINER=</code> clauses with the <code>sed</code></td>
      </tr>
      <tr>
          <td>The same <code>ERROR 1227</code> in the first lines, at <code>SET @@GLOBAL.GTID_PURGED=…</code></td>
          <td>MySQL</td>
          <td>The source has GTID enabled and <code>mysqldump</code> writes the position, which only <code>SUPER</code> can set</td>
          <td><code>--set-gtid-purged=OFF</code></td>
      </tr>
      <tr>
          <td><code>ERROR 1449 (HY000): The user specified as a definer ('app'@'localhost') does not exist</code> when <strong>using</strong> the view, not when importing</td>
          <td>MySQL</td>
          <td>You left a <code>DEFINER</code> for a user that does not exist on the instance</td>
          <td>Strip the <code>DEFINER</code> or create the user with that exact host</td>
      </tr>
      <tr>
          <td><code>ERROR: must be owner of extension plpgsql</code></td>
          <td>PostgreSQL</td>
          <td>The dump&rsquo;s <code>COMMENT ON EXTENSION plpgsql</code>: on Cloud SQL extensions belong to <code>cloudsqladmin</code></td>
          <td>Comment out the <code>EXTENSION</code> lines with the <code>sed</code></td>
      </tr>
      <tr>
          <td><code>ERROR: role &quot;app_owner&quot; does not exist</code></td>
          <td>PostgreSQL</td>
          <td><code>ALTER … OWNER TO</code> and <code>GRANT … TO</code> for roles from the old server</td>
          <td><code>--no-owner --no-acl</code>, or create the roles first</td>
      </tr>
      <tr>
          <td><code>ERROR: permission denied for schema public</code> or <code>must be owner of …</code> halfway through</td>
          <td>PostgreSQL</td>
          <td>You imported with a <code>--user</code> that does not own what already exists in the database</td>
          <td>Import as <code>postgres</code> into an empty database; reassign afterwards</td>
      </tr>
  </tbody>
</table>
<h2 id="mysql-what-to-do-with-definer-clauses">MySQL: what to do with DEFINER clauses</h2>
<p>First see how many there are on the source, and whose:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="k">DEFINER</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">INFORMATION_SCHEMA</span><span class="p">.</span><span class="n">VIEWS</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="k">DEFINER</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">INFORMATION_SCHEMA</span><span class="p">.</span><span class="n">TRIGGERS</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="k">DEFINER</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">INFORMATION_SCHEMA</span><span class="p">.</span><span class="n">ROUTINES</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">SELECT</span><span class="w"> </span><span class="k">DISTINCT</span><span class="w"> </span><span class="k">DEFINER</span><span class="w"> </span><span class="k">FROM</span><span class="w"> </span><span class="n">INFORMATION_SCHEMA</span><span class="p">.</span><span class="n">EVENTS</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Three ways out, from most to least faithful to the original:</p>
<ol>
<li><strong>Create the users on the instance before importing</strong>, with the same <code>user@host</code>: <code>gcloud sql users create app --host=% --instance=my-instance --password=…</code>. This is what the documentation recommends, and it keeps views and procedures exactly as they were. <code>root@localhost</code> is the exception: it cannot be created on Cloud SQL, so a <code>DEFINER</code> with that value must be changed no matter what.</li>
<li><strong>Strip the <code>DEFINER</code></strong> with the <code>sed</code> above: objects get created with the importing user as definer. Enough when the source users will never exist on Cloud SQL.</li>
<li><strong>Switch to <code>SQL SECURITY INVOKER</code></strong> (<code>sed -E 's/SQL SECURITY DEFINER/SQL SECURITY INVOKER/g'</code> on top of the previous one): the view or procedure runs with the caller&rsquo;s privileges and the definer stops mattering. It changes the permission model; check that the application users can read the underlying tables.</li>
</ol>
<p>The <code>sed</code> is deliberately broad because <code>DEFINER=</code> also shows up inside conditional comments (<code>/*!50013 DEFINER=… SQL SECURITY DEFINER */</code>); deleting just the <code>DEFINER=</code> leaves the rest intact and valid.</p>
<h2 id="mysql-the-flags-one-by-one">MySQL: the flags, one by one</h2>
<table>
  <thead>
      <tr>
          <th>Flag</th>
          <th>Why</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>--hex-blob</code></td>
          <td>Binary columns as hex; without it, a <code>BLOB</code> with odd bytes gets corrupted going through the character set</td>
      </tr>
      <tr>
          <td><code>--single-transaction</code></td>
          <td>Consistent InnoDB snapshot without locking tables; a must in production</td>
      </tr>
      <tr>
          <td><code>--set-gtid-purged=OFF</code></td>
          <td>Do not write <code>SET @@GLOBAL.GTID_PURGED</code>, which Cloud SQL will not let you run</td>
      </tr>
      <tr>
          <td><code>--default-character-set=utf8mb4</code></td>
          <td>Make the dump and the <code>SET NAMES</code> it carries match what is in the tables; avoids the classic <code>Ã©</code></td>
      </tr>
      <tr>
          <td><code>--databases app</code></td>
          <td>Required per the documentation, and never with the <code>mysql</code> database. Adds <code>CREATE DATABASE IF NOT EXISTS</code> and <code>USE app</code>, so <code>--database</code> on import becomes optional</td>
      </tr>
      <tr>
          <td><code>--routines --triggers --events</code></td>
          <td><code>mysqldump</code> only includes triggers by default; procedures, functions and events must be asked for. All of them carry a <code>DEFINER</code></td>
      </tr>
  </tbody>
</table>
<p>If the user you dump with lacks <code>PROCESS</code>, <code>mysqldump</code> 8.0.21+ fails right away with <em>&ldquo;Access denied; you need (at least one of) the PROCESS privilege(s)&rdquo;</em> while querying tablespaces: add <code>--no-tablespaces</code>.</p>
<h2 id="postgresql-plain-or-custom">PostgreSQL: plain or custom</h2>
<p><code>--no-owner --no-acl</code> make everything imported belong to <code>--user</code>, with no <code>GRANT</code> to phantom roles. If the application connects as another role, create the role first and use <code>--user=app</code>, or reassign afterwards:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="n">REASSIGN</span><span class="w"> </span><span class="n">OWNED</span><span class="w"> </span><span class="k">BY</span><span class="w"> </span><span class="n">postgres</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">app</span><span class="p">;</span><span class="w">   </span><span class="c1">-- connected as postgres, in the imported database
</span></span></span></code></pre></td></tr></table>
</div>
</div><p>Create the extensions the database needs (<code>pg_trgm</code>, <code>postgis</code>, <code>uuid-ossp</code>…) <strong>before</strong> importing, as <code>postgres</code>, in the target database: <code>CREATE EXTENSION IF NOT EXISTS pg_trgm;</code>. Only the ones on Cloud SQL&rsquo;s supported extensions list work; <code>plpgsql</code> is already there. With that in place, commenting out the dump&rsquo;s <code>CREATE EXTENSION</code> lines removes nothing.</p>
<p>If you prefer the <em>custom</em> format (<code>-Fc</code>), the extension filter goes into the <code>pg_restore</code> table of contents, exactly as Google documents it, and you restore through the Cloud SQL Auth Proxy:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">pg_dump app -h db.example -U dumper --format<span class="o">=</span>custom --no-owner --no-acl &gt; app.dmp
</span></span><span class="line"><span class="cl">pg_restore --list app.dmp <span class="p">|</span> sed -E <span class="s1">&#39;s/(.*EXTENSION )/; \1/g&#39;</span> &gt; app.toc
</span></span><span class="line"><span class="cl">pg_restore --use-list<span class="o">=</span>app.toc --no-owner --no-acl -j <span class="m">4</span> -h 127.0.0.1 -U postgres -d app app.dmp
</span></span></code></pre></td></tr></table>
</div>
</div><p><code>-j 4</code> restores in parallel, which the import from the bucket does not do.</p>
<h2 id="traps">Traps</h2>
<ul>
<li><strong>The bucket role goes to the instance&rsquo;s service account</strong>, not yours: the docs ask for <code>storage.objectAdmin</code> (for import alone <code>storage.objects.get</code> and <code>list</code>, that is <code>objectViewer</code>, are enough). Without it, <em>&ldquo;The service account does not have the required permissions for the bucket&rdquo;</em> even though you can read the object yourself.</li>
<li><strong>One operation at a time per instance</strong>: with a backup or export running, the import fails with <em>&ldquo;operation already in progress&rdquo;</em>. <code>gcloud sql operations list --instance=my-instance</code> shows it.</li>
<li><strong>Large dumps</strong>: the import is serial and slow; launch it with <code>--async</code> and watch it with <code>gcloud sql operations wait OPERATION_ID --timeout=unlimited</code>, or <code>gcloud</code> dies on you along the way.</li>
<li><strong><code>pg_dump</code> version</strong>: use the same major version as the target or lower. <code>pg_dump</code> 17 writes <code>SET transaction_timeout = 0;</code>, which a PostgreSQL 16 rejects with <em>&ldquo;unrecognized configuration parameter&rdquo;</em> and aborts the import.</li>
<li><strong><code>utf8mb4</code> on the instance</strong>: <code>--default-character-set=utf8mb4</code> in the dump does not fix an instance created with <code>latin1</code>; check <code>character_set_server</code> in the instance flags before importing.</li>
<li><strong>The DEFINER <code>sed</code> also touches data</strong>: if some row literally contains <code>DEFINER=`x`@`y` </code> (rare, but a CMS that stores SQL can), skip it with a pattern anchored to <code>CREATE</code>, or review the dump diff.</li>
</ul>
<h2 id="see-also">See also</h2>
<ul>
<li><a href="/en/posts/databases/postgresql-admin-cheat-sheet/">PostgreSQL: admin cheat sheet</a> — roles, <code>pg_dump</code>, <code>REASSIGN OWNED</code> and friends.</li>
<li><a href="https://docs.cloud.google.com/sql/docs/mysql/import-export/import-export-sql">Import and export using SQL dump files (MySQL)</a>, <a href="https://docs.cloud.google.com/sql/docs/mysql/import-export#definer-clause">DEFINER clause</a> and <a href="https://docs.cloud.google.com/sql/docs/postgres/import-export/import-export-dmp">pg_dump / pg_restore (PostgreSQL)</a> in the Cloud SQL documentation.</li>
</ul>
]]></content:encoded></item><item><title>Vault Agent Injector on a private GKE cluster: no sidecar and pods take 30 s (the control plane cannot reach the webhook on 8080)</title><link>https://inet.sh/en/posts/kubernetes/vault-agent-injector-gke-private-cluster-no-sidecar/</link><pubDate>Sat, 26 Sep 2026 00:00:00 +0000</pubDate><guid>https://inet.sh/en/posts/kubernetes/vault-agent-injector-gke-private-cluster-no-sidecar/</guid><description>On a private GKE cluster, pods annotated with vault.hashicorp.com/agent-inject start without the vault-agent-init and vault-agent containers, take half a minute longer to be created, and the injector logs no request at all. The control plane can only open connections to nodes on ports 443 and 10250; the injector listens on 8080 and the webhook fails silently because failurePolicy is Ignore. Firewall rule from the control plane range, how to validate in the injector logs, and the same problem with any other admission webhook.</description><content:encoded><![CDATA[<h2 id="tldr">TL;DR</h2>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span><span class="lnt">6
</span><span class="lnt">7
</span><span class="lnt">8
</span><span class="lnt">9
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl"><span class="nv">CLUSTER</span><span class="o">=</span>my-cluster<span class="p">;</span> <span class="nv">REGION</span><span class="o">=</span>us-central1<span class="p">;</span> <span class="nv">NETWORK</span><span class="o">=</span>my-vpc
</span></span><span class="line"><span class="cl"><span class="nv">MASTER_CIDR</span><span class="o">=</span><span class="k">$(</span>gcloud container clusters describe <span class="nv">$CLUSTER</span> --region <span class="nv">$REGION</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s1">&#39;value(privateClusterConfig.masterIpv4CidrBlock)&#39;</span><span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">NODE_TAG</span><span class="o">=</span><span class="k">$(</span>gcloud compute firewall-rules list --filter<span class="o">=</span><span class="s2">&#34;name~^gke-</span><span class="nv">$CLUSTER</span><span class="s2">&#34;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --format<span class="o">=</span><span class="s1">&#39;value(targetTags.list())&#39;</span> <span class="p">|</span> head -1<span class="k">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">gcloud compute firewall-rules create gke-<span class="nv">$CLUSTER</span>-master-to-vault-injector <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --network<span class="o">=</span><span class="nv">$NETWORK</span> --direction<span class="o">=</span>INGRESS --action<span class="o">=</span>ALLOW <span class="se">\
</span></span></span><span class="line"><span class="cl"><span class="se"></span>  --source-ranges<span class="o">=</span><span class="nv">$MASTER_CIDR</span> --target-tags<span class="o">=</span><span class="nv">$NODE_TAG</span> --rules<span class="o">=</span>tcp:8080
</span></span></code></pre></td></tr></table>
</div>
</div><p>Delete an annotated pod and check that the injector gets the call:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt">1
</span><span class="lnt">2
</span><span class="lnt">3
</span><span class="lnt">4
</span><span class="lnt">5
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-shell" data-lang="shell"><span class="line"><span class="cl">kubectl -n app delete pod -l <span class="nv">app</span><span class="o">=</span>api
</span></span><span class="line"><span class="cl">kubectl -n vault logs deploy/vault-agent-injector <span class="p">|</span> grep mutate
</span></span><span class="line"><span class="cl"><span class="c1"># [INFO]  handler: Request received: Method=POST URL=/mutate?timeout=30s</span>
</span></span><span class="line"><span class="cl">kubectl -n app get pod -l <span class="nv">app</span><span class="o">=</span>api -o <span class="nv">jsonpath</span><span class="o">=</span><span class="s1">&#39;{.items[0].spec.initContainers[*].name} {.items[0].spec.containers[*].name}{&#34;\n&#34;}&#39;</span>
</span></span><span class="line"><span class="cl"><span class="c1"># vault-agent-init api vault-agent</span>
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="the-symptom">The symptom</h2>
<ul>
<li>The pod carries <code>vault.hashicorp.com/agent-inject: &quot;true&quot;</code> and the rest of the annotations, but starts <strong>with the application container only</strong>: no <code>vault-agent-init</code>, no <code>vault-agent</code>.</li>
<li>Creating the pod <strong>takes about 30 seconds longer</strong> than usual.</li>
<li><code>kubectl get events</code> says nothing about the webhook. The injector logs do not have a single <code>Request received</code> line.</li>
<li>On other clusters (public zonal, kind, k3s) the same chart and the same annotations work.</li>
</ul>
<h2 id="why">Why</h2>
<p>On a <strong>private</strong> GKE cluster the control plane lives in a Google-owned VPC and reaches your nodes through the firewall rule GKE creates by itself (<code>gke-&lt;cluster&gt;-&lt;hash&gt;-master</code>). That rule only allows <strong>TCP 443 and 10250</strong> from the control plane range to the nodes. Everything else is closed.</p>
<p>An admission webhook does not go through the Service like a normal client would: the API server resolves the Service to its endpoints and <strong>connects straight to the pod IP on the <code>targetPort</code></strong>. The injector&rsquo;s Service (<code>vault-agent-injector-svc</code>) exposes 443, but the pod listens on <strong>8080</strong> (<code>injector.port</code> in the chart), so the actual connection goes to 8080 and the firewall drops it.</p>
<p>What happens next is explained by two chart defaults:</p>
<table>
  <thead>
      <tr>
          <th>Value</th>
          <th>Default</th>
          <th>Effect</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>injector.webhook.timeoutSeconds</code></td>
          <td><code>30</code></td>
          <td>The API server waits 30 s for the webhook&rsquo;s answer</td>
      </tr>
      <tr>
          <td><code>injector.webhook.failurePolicy</code></td>
          <td><code>Ignore</code></td>
          <td>If the webhook does not answer, the pod is created <strong>unmutated</strong>, with no error</td>
      </tr>
  </tbody>
</table>
<p>Hence the 30-second delay and the pod with no sidecar and no message. With <code>failurePolicy: Fail</code> you would have seen <code>failed calling webhook &quot;vault.hashicorp.com&quot;: … context deadline exceeded</code> in the ReplicaSet events, which at least points at the right place.</p>
<h2 id="not-just-vault">Not just Vault</h2>
<p>Any admission webhook, CRD conversion webhook or aggregated API listening on a port other than 443 or 10250 fails the same way on private GKE: the OpenTelemetry operator (9443), Kyverno (9443), Gatekeeper (8443)… cert-manager listens on <strong>10250</strong> precisely to avoid needing this rule. The GKE documentation lists it as a requirement for &ldquo;admission webhooks, aggregated API servers, webhook conversion&rdquo;: one rule per port, from <code>masterIpv4CidrBlock</code> to the node tags.</p>
<p>If the cluster lives in Terraform, keep the rule next to the cluster so it survives a rebuild:</p>
<div class="highlight"><div class="chroma">
<table class="lntable"><tr><td class="lntd">
<pre tabindex="0" class="chroma"><code><span class="lnt"> 1
</span><span class="lnt"> 2
</span><span class="lnt"> 3
</span><span class="lnt"> 4
</span><span class="lnt"> 5
</span><span class="lnt"> 6
</span><span class="lnt"> 7
</span><span class="lnt"> 8
</span><span class="lnt"> 9
</span><span class="lnt">10
</span><span class="lnt">11
</span></code></pre></td>
<td class="lntd">
<pre tabindex="0" class="chroma"><code class="language-hcl" data-lang="hcl"><span class="line"><span class="cl"><span class="k">resource</span> <span class="s2">&#34;google_compute_firewall&#34; &#34;master_to_vault_injector&#34;</span> {
</span></span><span class="line"><span class="cl"><span class="n">  name</span>          <span class="o">=</span> <span class="s2">&#34;gke-${google_container_cluster.main.name}-master-to-vault-injector&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  network</span>       <span class="o">=</span> <span class="k">google_compute_network</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">name</span>
</span></span><span class="line"><span class="cl"><span class="n">  direction</span>     <span class="o">=</span> <span class="s2">&#34;INGRESS&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">  source_ranges</span> <span class="o">=</span> <span class="p">[</span><span class="k">google_container_cluster</span><span class="p">.</span><span class="k">main</span><span class="p">.</span><span class="k">private_cluster_config</span><span class="p">[</span><span class="m">0</span><span class="p">].</span><span class="k">master_ipv4_cidr_block</span><span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="n">  target_tags</span>   <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;gke-${google_container_cluster.main.name}&#34;</span><span class="p">]</span><span class="c1">   # the tag your node pools use
</span></span></span><span class="line"><span class="cl"><span class="c1"></span>  <span class="k">allow</span> {
</span></span><span class="line"><span class="cl"><span class="n">    protocol</span> <span class="o">=</span> <span class="s2">&#34;tcp&#34;</span>
</span></span><span class="line"><span class="cl"><span class="n">    ports</span>    <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;8080&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  }
</span></span><span class="line"><span class="cl">}
</span></span></code></pre></td></tr></table>
</div>
</div><h2 id="traps">Traps</h2>
<ul>
<li><strong>The wrong tag</strong>: nodes carry <code>gke-&lt;cluster&gt;-&lt;hash&gt;-node</code>; copy it from the existing <code>gke-&lt;cluster&gt;-*</code> rules instead of typing it. With several node pools, check that all of them have the tag.</li>
<li><strong>The control plane range changes if you recreate the cluster</strong>; a hand-written rule with the old CIDR silently stops working.</li>
<li><strong><code>failurePolicy: Fail</code></strong> makes the failure visible, but while the webhook is down <strong>no pod gets created</strong> in the namespaces matching the <code>namespaceSelector</code>; the chart excludes the injector itself, but not Vault if they share namespace and labels. Use it with <code>replicas: 2</code>.</li>
<li><strong>The injector does receive the request and there is still no sidecar</strong>: then it is no longer the network. Read the <code>vault-agent-init</code> logs: <code>permission denied</code> is the Kubernetes auth role (<code>bound_service_account_names</code>, <code>bound_service_account_namespaces</code>) or the policy; <code>connection refused</code> is <code>vault.hashicorp.com/service</code> or the Vault address.</li>
<li><strong>One port per rule does not scale</strong>: as webhooks pile up, one rule with <code>--rules=tcp:8080,8443,9443</code> from the control plane is easier to maintain than five.</li>
</ul>
<p>References: <a href="https://github.com/hashicorp/vault-k8s/issues/46">vault-k8s #46</a>, <a href="https://github.com/hashicorp/vault-k8s/issues/32">vault-k8s #32</a> and <a href="https://docs.cloud.google.com/kubernetes-engine/docs/how-to/private-clusters">firewall rules on private clusters</a> in the GKE documentation.</p>
<h2 id="see-also">See also</h2>
<ul>
<li><a href="/en/posts/kubernetes/cert-manager-http01-308-ingressclassname/">cert-manager: the HTTP-01 challenge returns 308</a> — another component that &ldquo;does nothing&rdquo; until you look in the right place.</li>
<li><a href="/en/posts/gcp/github-actions-workload-identity-federation/">GitHub Actions → Google Cloud without keys</a> — to deploy to that GKE without JSON keys.</li>
</ul>
]]></content:encoded></item></channel></rss>