Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions .github/workflows/cli-go-mirror.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,11 @@ name: Mirror Dependencies
# ghcr.io, and AWS ECR.

on:
# We can't trigger the mirror job on PR merge because certain tests would fail
# until we mirror some images. E.g. a PR to update the imgproxy image version
# would fail, because there is a test that creates a container from the
# updated image version, which would fail because the image hasn't been
# mirrored yet. It's a catch-22!
#
# TODO: Make the cli start test run *after* we mirror images (if needed).
# This workflow is the manual/bulk entry point for re-mirroring everything.
# Template image bumps are mirrored automatically by mirror-template-images.yml
# on push to develop, which backfills any unmirrored tag when the templates
# Dockerfile changes — so develop and PRs rebased on it stop inheriting the
# `manifest unknown` failure in the ghcr.io-pinned `Start` check.
workflow_dispatch:
permissions:
contents: read
Expand Down
83 changes: 83 additions & 0 deletions .github/workflows/mirror-template-images.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
name: Mirror template images

# Keeps the ghcr.io/ECR mirror in sync with the image versions pinned in
# apps/cli-go/pkg/config/templates/Dockerfile (the single source of truth for
# `config.Images`). When the Dockerfile changes on develop — most often via a
# merged dependabot `docker` bump — this workflow detects any tag that is not
# yet mirrored and backfills it the same way `cli-go-mirror-image.yml` does.
#
# It runs on `push` to develop (not on the PR) on purpose: mirroring needs the
# AWS role + packages:write, which a dependabot-triggered `pull_request` run
# cannot be granted, and we deliberately avoid `pull_request_target`. The CI
# `Start` job pins SUPABASE_INTERNAL_IMAGE_REGISTRY=ghcr.io, so it only goes
# green once a bumped tag is mirrored here; this backfill runs as soon as the
# bump lands on develop, repopulating ghcr.io/ECR so develop and any PR rebased
# on it pass `Start` instead of inheriting a `manifest unknown` failure.

on:
push:
branches:
- develop
paths:
- apps/cli-go/pkg/config/templates/Dockerfile
Comment thread
avallete marked this conversation as resolved.
workflow_dispatch:

permissions:
contents: read

concurrency:
group: mirror-template-images-${{ github.ref }}
cancel-in-progress: false

jobs:
detect:
name: Detect unmirrored images
runs-on: ubuntu-latest
permissions:
contents: read
packages: read
outputs:
missing: ${{ steps.detect.outputs.missing }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Setup
uses: ./.github/actions/setup
with:
dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }}

- name: Log in to ghcr.io
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

# Parses the Dockerfile, checks each image against the mirror, and writes
# `missing=<json>` to $GITHUB_OUTPUT. Idempotent: already-mirrored images
# are skipped, so a re-run produces an empty list.
- name: Detect images missing from the mirror
id: detect
run: pnpm exec bun apps/cli/scripts/detect-unmirrored-images.ts

mirror:
name: Mirror image
needs: detect
if: needs.detect.outputs.missing != '' && needs.detect.outputs.missing != '[]'
permissions:
contents: read
packages: write
id-token: write
strategy:
fail-fast: false
matrix:
image: ${{ fromJson(needs.detect.outputs.missing) }}
# Reuse the existing mirror logic (docker.io -> public.ecr.aws + ghcr.io).
uses: ./.github/workflows/cli-go-mirror-image.yml
with:
image: ${{ matrix.image }}
secrets:
PROD_AWS_ROLE: ${{ secrets.PROD_AWS_ROLE }}
105 changes: 105 additions & 0 deletions apps/cli/scripts/detect-unmirrored-images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are
// not yet present on every mirror registry and emits the missing ones as JSON.
// Used by the mirror-template-images workflow to drive the backfill matrix.
//
// It checks every image and skips the ones already mirrored everywhere, so
// re-running after a successful mirror is a no-op. The exported helpers are
// unit-tested in detect-unmirrored-images.unit.test.ts; the entry block below
// (guarded by import.meta.main) performs the only side effects.
import { spawnSync } from "node:child_process";
import { appendFileSync } from "node:fs";
import process from "node:process";
import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts";

/**
* Registries the mirror publishes to and the CLI pulls from, mirroring Go's
* `utils.GetRegistryImageUrls` (`defaultRegistry` + `ghcrRegistry`). An image
* counts as mirrored only when it exists on EVERY one of these — the mirror
* pushes to all of them at once, so a tag present on one but not another is a
* partial mirror that must be re-pushed.
*/
export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const;

/**
* Mirror destination for an upstream image on a single registry, mirroring Go's
* `utils.GetRegistryImageUrl` (`registry + "/supabase/" + basename`). The
* upstream org is dropped — every image is mirrored under the `supabase/`
* namespace — e.g. `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`.
*/
export function mirrorImageTarget(image: string, registry: string): string {
const basename = image.slice(image.lastIndexOf("/") + 1);
return `${registry}/supabase/${basename}`;
}

/** Mirror destinations for an upstream image across every mirror registry. */
export function mirrorImageTargets(
image: string,
registries: ReadonlyArray<string> = MIRROR_REGISTRIES,
): ReadonlyArray<string> {
return registries.map((registry) => mirrorImageTarget(image, registry));
}

export interface MirrorPartition {
/** Images present on every mirror registry — nothing to do. */
readonly mirrored: ReadonlyArray<string>;
/** Images missing from at least one mirror registry — these need backfilling. */
readonly missing: ReadonlyArray<string>;
}

/**
* Split images by whether they are fully mirrored — present on EVERY registry in
* `registries`. An image missing from any one registry lands in `missing` so the
* backfill re-pushes it everywhere. Every (image, registry) pair is queried, each
* distinct image once. No image is skipped up front — a `supabase/*` image that is
* somehow absent is reported just like a third-party one. Idempotent: once an
* image is on all registries, a re-run skips it.
*/
export async function partitionUnmirroredImages(
images: Iterable<string>,
isMirrored: (target: string) => Promise<boolean>,
registries: ReadonlyArray<string> = MIRROR_REGISTRIES,
): Promise<MirrorPartition> {
const unique = [...new Set(images)];
const results = await Promise.all(
unique.map(async (image) => {
const presence = await Promise.all(
mirrorImageTargets(image, registries).map((target) => isMirrored(target)),
);
return { image, mirrored: presence.every(Boolean) };
}),
);

return {
mirrored: results.filter((result) => result.mirrored).map((result) => result.image),
missing: results.filter((result) => !result.mirrored).map((result) => result.image),
};
}

// An image counts as mirrored only when this returns true for every registry
// target; both are queried per image by the partition above.
function imageExistsOnMirror(target: string): Promise<boolean> {
const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], {
stdio: "ignore",
});
return Promise.resolve(result.status === 0);
}

if (import.meta.main) {
const images = dockerfileServiceImages.map((spec) => spec.image);
const { mirrored, missing } = await partitionUnmirroredImages(images, imageExistsOnMirror);

for (const image of mirrored) {
console.error(`already mirrored: ${image}`);
}
for (const image of missing) {
console.error(`needs mirror: ${image} -> ${mirrorImageTargets(image).join(", ")}`);
}

const json = JSON.stringify(missing);
console.log(json);

// Expose the list to the workflow as a step output when running in CI.
if (process.env.GITHUB_OUTPUT) {
appendFileSync(process.env.GITHUB_OUTPUT, `missing=${json}\n`);
}
}
65 changes: 65 additions & 0 deletions apps/cli/scripts/detect-unmirrored-images.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, test } from "vitest";
import {
MIRROR_REGISTRIES,
mirrorImageTarget,
mirrorImageTargets,
partitionUnmirroredImages,
} from "./detect-unmirrored-images.ts";

describe("detect unmirrored images", () => {
test("mirrors an upstream image under the supabase namespace of a registry", () => {
// Third-party orgs are dropped; only the basename is kept, matching Go's
// utils.GetRegistryImageUrl.
expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe(
"ghcr.io/supabase/postgrest:v14.14",
);
expect(mirrorImageTarget("library/kong:2.8.1", "public.ecr.aws")).toBe(
"public.ecr.aws/supabase/kong:2.8.1",
);
});

test("targets cover every mirror registry (ECR and ghcr.io)", () => {
expect(MIRROR_REGISTRIES).toEqual(["public.ecr.aws", "ghcr.io"]);
expect(mirrorImageTargets("postgrest/postgrest:v14.14")).toEqual([
"public.ecr.aws/supabase/postgrest:v14.14",
"ghcr.io/supabase/postgrest:v14.14",
]);
});

test("an image is mirrored only when present on ALL registries", async () => {
const present = new Set([
// kong is on both registries -> mirrored.
"public.ecr.aws/supabase/kong:2.8.1",
"ghcr.io/supabase/kong:2.8.1",
// postgrest is only on ghcr.io -> partial mirror, must be re-pushed.
"ghcr.io/supabase/postgrest:v14.14",
]);
const queried: string[] = [];
const isMirrored = (target: string) => {
queried.push(target);
return Promise.resolve(present.has(target));
};

const { mirrored, missing } = await partitionUnmirroredImages(
// Duplicate kong to prove de-duplication.
["library/kong:2.8.1", "postgrest/postgrest:v14.14", "library/kong:2.8.1"],
isMirrored,
);

expect(mirrored).toEqual(["library/kong:2.8.1"]);
expect(missing).toEqual(["postgrest/postgrest:v14.14"]);
// Two unique images x two registries = four checks.
expect(queried).toHaveLength(4);
});

test("is a no-op once everything is on every registry (idempotent re-run)", async () => {
const allMirrored = () => Promise.resolve(true);
const { mirrored, missing } = await partitionUnmirroredImages(
["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"],
allMirrored,
);

expect(missing).toEqual([]);
expect(mirrored).toEqual(["postgrest/postgrest:v14.14", "supabase/logflare:1.45.6"]);
});
});
Loading