From 7f1d445981bb898e2f284522851c40114a75f6e1 Mon Sep 17 00:00:00 2001 From: dmgnr Date: Sun, 30 Aug 2026 18:51:19 +0700 Subject: [PATCH] feat: add garage tooling --- README.md | 18 +++++++ command/main.ts | 4 +- command/s3.ts | 89 ++++++++++++++++++++++++++++++++++ lib/storage.ts | 4 +- package.json | 2 +- tests/command/metadata.test.ts | 20 +++++++- tests/lib/storage.test.ts | 7 +++ 7 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 command/s3.ts diff --git a/README.md b/README.md index 0a78de6..eb05f1d 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,9 @@ bun run index.ts start bun run index.ts stop bun run index.ts restart bun run index.ts db ls +bun run index.ts s3 ls +bun run index.ts s3 creds app +bun run index.ts s3 ui app ``` All commands accept `--config` to use a configuration file other than @@ -102,6 +105,9 @@ For a permanent setup, write the generated script to a file and source it from y - `exec `: execute a command inside a running deployment pod - `db ls`: list managed Postgres claims declared in the current Compose file - `db creds `: print the generated connection details for a managed Postgres claim +- `s3 ls`: list managed S3 claims declared in the current Compose file +- `s3 creds `: print all generated S3 environment variables for a service +- `s3 ui `: print the Garage UI object-browser URL for a service bucket ## Configuration @@ -255,6 +261,18 @@ The Garage operator generates the credentials. `kuber` reads its generated Secre One service can declare both `postgresql:...` and `s3:...`; all generated values are merged into the same service Secret. Managed Garage buckets and keys are retained by normal `down` and deleted by `down -f`. +Inspect a claim, print its generated credentials, or get its Garage UI URL: + +```bash +kuber s3 ls +kuber s3 creds app +kuber s3 ui app +``` + +`s3 creds` prints `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`AWS_ENDPOINT_URL_S3`, `AWS_REGION`, and `S3_BUCKET` as shell-style environment +assignments. `s3 ui` only prints the URL; it does not open a browser. + ### Environment Files `env_file` entries are read locally and turned into a Kubernetes `Secret` named `-env`. Deployments then consume that secret through `envFrom`. diff --git a/command/main.ts b/command/main.ts index 72729ce..4c7ffc7 100644 --- a/command/main.ts +++ b/command/main.ts @@ -7,6 +7,7 @@ import { exportCommand } from "./export"; import { logs } from "./logs"; import { ps } from "./ps"; import { restart } from "./restart"; +import { s3 } from "./s3"; import { start } from "./start"; import { stop } from "./stop"; import { up } from "./up"; @@ -14,7 +15,7 @@ import { up } from "./up"; export const main = defineCommand({ meta: { name: "kuber", - version: "1.1.0", + version: "1.2.0", description: "Docker Compose -> K8s translation layer", }, args: { @@ -31,6 +32,7 @@ export const main = defineCommand({ logs, ps, restart, + s3, start, stop, up, diff --git a/command/s3.ts b/command/s3.ts new file mode 100644 index 0000000..c83c942 --- /dev/null +++ b/command/s3.ts @@ -0,0 +1,89 @@ +import { defineCommand } from "citty"; +import { ctx } from "../lib/context"; +import { toTable } from "../lib/format"; +import { + getComposeS3Claims, + getS3Credentials, + type S3Claim, +} from "../lib/storage"; + +const GARAGE_UI_URL = "http://garage-ui.garage-system.svc.cluster.local"; + +export function getS3UiUrl(bucket: string): string { + return `${GARAGE_UI_URL}/buckets/${encodeURIComponent(bucket)}/objects`; +} + +async function getServiceClaim(service: string): Promise { + const claim = getComposeS3Claims(await ctx().compose()).find( + (entry) => entry.service === service, + ); + if (!claim) { + throw new Error(`Service ${service} does not declare a managed S3 volume.`); + } + return claim; +} + +const list = defineCommand({ + meta: { + name: "ls", + description: "List managed S3 claims", + }, + async run() { + const claims = getComposeS3Claims(await ctx().compose()); + if (claims.length === 0) { + console.log("No managed S3 volumes declared"); + return; + } + + console.log( + toTable( + claims.map((claim) => ({ + service: claim.service, + key: claim.key, + bucket: claim.bucket, + })), + ), + ); + }, +}); + +const creds = defineCommand({ + meta: { + name: "creds", + description: "Print S3 environment variables for a service", + }, + async run({ args }) { + const service = args._[0]; + if (!service) throw new Error("Service name is required"); + + const credentials = await getS3Credentials(await getServiceClaim(service)); + for (const [name, value] of Object.entries(credentials)) { + console.log(`${name}=${value}`); + } + }, +}); + +const ui = defineCommand({ + meta: { + name: "ui", + description: "Print the Garage UI URL for a service bucket", + }, + async run({ args }) { + const service = args._[0]; + if (!service) throw new Error("Service name is required"); + + console.log(getS3UiUrl((await getServiceClaim(service)).bucket)); + }, +}); + +export const s3 = defineCommand({ + meta: { + name: "s3", + description: "Inspect managed S3 storage", + }, + subCommands: { + creds, + ls: list, + ui, + }, +}); diff --git a/lib/storage.ts b/lib/storage.ts index 91aa4cd..58b7725 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -225,7 +225,7 @@ async function reconcileKeys( } } -async function readKeyEnvironment( +export async function getS3Credentials( claim: S3Claim, ): Promise> { const deadline = Date.now() + SECRET_WAIT_TIMEOUT_MS; @@ -297,7 +297,7 @@ export async function reconcileS3Claims( const environmentsByKey = new Map>(); for (const claim of claims) { if (environmentsByKey.has(claim.key)) continue; - environmentsByKey.set(claim.key, await readKeyEnvironment(claim)); + environmentsByKey.set(claim.key, await getS3Credentials(claim)); } return Object.fromEntries( diff --git a/package.json b/package.json index 4e5498e..f6395d0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dmgnr/kuber", - "version": "1.1.0", + "version": "1.2.0", "description": "Docker Compose to Kubernetes translation layer", "bin": { "kuber": "dist/index.js" diff --git a/tests/command/metadata.test.ts b/tests/command/metadata.test.ts index 8a1da56..e1f0579 100644 --- a/tests/command/metadata.test.ts +++ b/tests/command/metadata.test.ts @@ -7,6 +7,7 @@ import { logs } from "../../command/logs"; import { initializeCompletion, main } from "../../command/main"; import { ps } from "../../command/ps"; import { restart } from "../../command/restart"; +import { s3 } from "../../command/s3"; import { start } from "../../command/start"; import { stop } from "../../command/stop"; import { up } from "../../command/up"; @@ -24,6 +25,7 @@ describe("CLI command definitions", () => { ["start", "Start deployments without rebuilding images", start], ["stop", "Scale managed deployments to zero", stop], ["restart", "Roll out a restart for managed deployments", restart], + ["s3", "Inspect managed S3 storage", s3], ["down", "Delete managed resources except ingress and PVCs", down], ["ps", "List deployments", ps], ["logs", "Show deployment logs", logs], @@ -37,7 +39,8 @@ describe("CLI command definitions", () => { (name, description, command) => { const definition = command as Command; expect(definition.meta).toEqual({ name, description }); - if (name !== "db") expect(typeof definition.run).toBe("function"); + if (name !== "db" && name !== "s3") + expect(typeof definition.run).toBe("function"); }, ); @@ -77,6 +80,21 @@ describe("CLI command definitions", () => { expect(typeof subCommands?.creds?.run).toBe("function"); }); + test("defines S3 inspection subcommands", () => { + const subCommands = (s3 as Command).subCommands; + expect(Object.keys(subCommands ?? {}).sort()).toEqual([ + "creds", + "ls", + "ui", + ]); + expect(subCommands?.ls?.meta?.name).toBe("ls"); + expect(subCommands?.creds?.meta?.name).toBe("creds"); + expect(subCommands?.ui?.meta?.name).toBe("ui"); + expect(typeof subCommands?.ls?.run).toBe("function"); + expect(typeof subCommands?.creds?.run).toBe("function"); + expect(typeof subCommands?.ui?.run).toBe("function"); + }); + test("registers shell completion on the root command", async () => { await initializeCompletion(); expect((await Promise.resolve(main.meta))?.name).toBe("kuber"); diff --git a/tests/lib/storage.test.ts b/tests/lib/storage.test.ts index 1568c58..7b20840 100644 --- a/tests/lib/storage.test.ts +++ b/tests/lib/storage.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { ComposeSpecification, Service } from "../../schema/docker.d"; import { serviceToDeployment, volumesToPvc } from "../../lib/convert"; +import { getS3UiUrl } from "../../command/s3"; import { getComposeS3Claims, getServiceS3Claim, @@ -89,4 +90,10 @@ describe("managed S3 claims", () => { { secretRef: { name: "app-env" } }, ]); }); + + test("builds a Garage UI object URL for a bucket", () => { + expect(getS3UiUrl("shared assets")).toBe( + "http://garage-ui.garage-system.svc.cluster.local/buckets/shared%20assets/objects", + ); + }); });