@izykitten/ce 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/bin/ce +12 -0
- package/bin/docker-credential-ce-forgejo +5 -0
- package/package.json +26 -0
- package/src/ce.ts +1189 -0
- package/src/libc.ts +104 -0
package/README.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# @izykitten/ce
|
|
2
|
+
|
|
3
|
+
Container engine wrapper for CI: registry login (including Forgejo OIDC), tag and cache rewriting for `docker`, `podman` and `buildah`, and a Docker credential helper that refreshes the Forgejo token.
|
|
4
|
+
|
|
5
|
+
A Bun port of the Python `ce` script from the `code.izy.sh/library/runner` image. Same commands, flags, environment variables, messages, exit codes and on-disk files, so a workflow only has to swap `ce` for `bunx`. Any job with Bun and an engine CLI can use it; it no longer needs the runner image.
|
|
6
|
+
|
|
7
|
+
## Usage
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
bunx @izykitten/ce login
|
|
11
|
+
bunx @izykitten/ce docker buildx build --push --cache type=gha,mode=max -t "$SERVER/$REPO:$REF,latest" .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
When installed as a dependency, call it as `ce` (`bunx ce …`). Run `ce` with no arguments for the full help, including every environment variable `login` reads.
|
|
15
|
+
|
|
16
|
+
In a Forgejo workflow:
|
|
17
|
+
|
|
18
|
+
```yaml
|
|
19
|
+
- uses: oven-sh/setup-bun@v2
|
|
20
|
+
- name: Log in to Registry
|
|
21
|
+
env:
|
|
22
|
+
REGISTRY_USER: ${{ forgejo.actor }}
|
|
23
|
+
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_TOKEN }}
|
|
24
|
+
REGISTRY_HOST: ${{ forgejo.server_url }}
|
|
25
|
+
run: bunx @izykitten/ce login
|
|
26
|
+
- name: Container Build & Push
|
|
27
|
+
run: bunx @izykitten/ce docker buildx build --push -t ${{ forgejo.server_url }}/${{ forgejo.repository }}:${{ forgejo.ref_name }} .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## The credential helper
|
|
31
|
+
|
|
32
|
+
With `AUD` set (and `enable-openid-connect: true` on the job), `ce login` logs in to the Forgejo registry with a short-lived OIDC token. It then writes `"credHelpers": {"<host>": "ce-forgejo"}` to the Docker config. From then on, Docker gets credentials for that host by running `docker-credential-ce-forgejo get`, which returns the saved token or fetches a fresh one when it is within five minutes of expiry. That is what lets a long build still push.
|
|
33
|
+
|
|
34
|
+
The package installs `docker-credential-ce-forgejo` next to `ce`. `ce` puts that directory on `PATH` for the engines it starts, so `ce docker …` finds the helper. A plain `docker` command in a later step does not get that `PATH`, so run pushes to an OIDC registry through `ce`.
|
|
35
|
+
|
|
36
|
+
## Differences from the Python version
|
|
37
|
+
|
|
38
|
+
- Replacing the process with the engine (`execvp`) and the state-file lock (`flock`) go through `bun:ffi` to libc, since Bun has no API for either. Tested on glibc (Debian) and musl (Alpine). It takes the same kernel lock as the Python version, so both can share a Docker config.
|
|
39
|
+
- The `Usage:` line shows the absolute path of the script, because Bun reports it that way.
|
|
40
|
+
- Error text that comes from the system, network or JSON parser is Bun's wording, not Python's (for example `ENOENT: no such file or directory` rather than `[Errno 2] No such file or directory`). Messages written by `ce` itself are unchanged.
|
|
41
|
+
- `docker-credential-ce-forgejo` runs the helper code directly instead of `exec ce credential-helper`, so `ce` does not have to be on `PATH`.
|
|
42
|
+
- JSON files keep the same structure and sorted keys. Numbers are written as JavaScript writes them (`4102444800` rather than `4102444800.0`), and non-ASCII characters are not `\u`-escaped.
|
|
43
|
+
|
|
44
|
+
## Development
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
bun install
|
|
48
|
+
bun test # the ported test_ce.py suite plus process tests for exec and flock
|
|
49
|
+
bun check
|
|
50
|
+
```
|
package/bin/ce
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { entry } from "../src/ce.ts";
|
|
4
|
+
|
|
5
|
+
// The wrapper is only meaningful when invoked as `ce`; reject any other basename.
|
|
6
|
+
const self = path.basename(process.argv[1] ?? "");
|
|
7
|
+
if (self !== "ce") {
|
|
8
|
+
process.stderr.write(`Unknown wrapper name: ${self}\n`);
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
await entry(process.argv.slice(2));
|
package/package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@izykitten/ce",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Container engine wrapper: tag/cache rewriting for docker, podman and buildah, registry login, and a Forgejo OIDC Docker credential helper",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "UNLICENSED",
|
|
7
|
+
"public": true,
|
|
8
|
+
"bin": {
|
|
9
|
+
"ce": "bin/ce",
|
|
10
|
+
"docker-credential-ce-forgejo": "bin/docker-credential-ce-forgejo"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin/",
|
|
14
|
+
"src/"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"bun": ">=1.4.3"
|
|
18
|
+
},
|
|
19
|
+
"os": [
|
|
20
|
+
"linux",
|
|
21
|
+
"darwin"
|
|
22
|
+
],
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/bun": "1.4.3"
|
|
25
|
+
}
|
|
26
|
+
}
|
package/src/ce.ts
ADDED
|
@@ -0,0 +1,1189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Container engine wrapper.
|
|
3
|
+
*
|
|
4
|
+
* A python -> Bun port of the `ce` script. `ce` with no args prints help and
|
|
5
|
+
* exits 0; otherwise `ce [--tag-prefix=<p>] [--cache-prefix=<p>] <command|engine>
|
|
6
|
+
* [args...]`.
|
|
7
|
+
*
|
|
8
|
+
* For engine passthrough, the wrapper rewrites:
|
|
9
|
+
* - `-t`/`--tag` values: strip https://, sanitize, move leading path
|
|
10
|
+
* segments to the repo side, and rewrite "<path>/renovate_<x>" to
|
|
11
|
+
* "<path>/renovate:<x>". Each tag gets the sanitized `--tag-prefix`
|
|
12
|
+
* prepended.
|
|
13
|
+
* - `--cache-from`/`--cache-to` (and the `--cache` shorthand): gha cache
|
|
14
|
+
* scopes get the sanitized `--cache-prefix` prepended; cache-from values
|
|
15
|
+
* that match a cache-to value are namespaced, unmatched cache-from
|
|
16
|
+
* values pass through unchanged so reads from a shared cache still work.
|
|
17
|
+
*
|
|
18
|
+
* The final engine invocation uses execve(2) so the wrapper is replaced in
|
|
19
|
+
* place; docker/podman/buildah inherits the same PID, signals, TTY and exit
|
|
20
|
+
* code. `login` spawns the engines because it loops and must continue on
|
|
21
|
+
* partial failure.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import path from "node:path";
|
|
27
|
+
import { execve, flock, LOCK_EX, strerror } from "./libc.ts";
|
|
28
|
+
|
|
29
|
+
export const SELF = "ce";
|
|
30
|
+
|
|
31
|
+
const FORGEJO_HELPER_SUFFIX = "ce-forgejo";
|
|
32
|
+
const FORGEJO_STATE_FILE = "ce-forgejo-credentials.json";
|
|
33
|
+
const FORGEJO_REFRESH_WINDOW = 5 * 60;
|
|
34
|
+
|
|
35
|
+
/** Raised for bad input; the entry point prints it and exits 2. */
|
|
36
|
+
export class ValueError extends Error {
|
|
37
|
+
override name = "ValueError";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isOSError(exc: unknown): exc is Error & { code: string } {
|
|
41
|
+
return exc instanceof Error && typeof (exc as { code?: unknown }).code === "string";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function message(exc: unknown): string {
|
|
45
|
+
return exc instanceof Error ? exc.message : String(exc);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Python-style repr() of a string, used in the messages carried over from `ce`. */
|
|
49
|
+
function repr(value: string): string {
|
|
50
|
+
const quote = value.includes("'") && !value.includes('"') ? '"' : "'";
|
|
51
|
+
const body = value
|
|
52
|
+
.replaceAll("\\", "\\\\")
|
|
53
|
+
.replaceAll(quote, `\\${quote}`)
|
|
54
|
+
.replaceAll("\n", "\\n")
|
|
55
|
+
.replaceAll("\r", "\\r")
|
|
56
|
+
.replaceAll("\t", "\\t");
|
|
57
|
+
return `${quote}${body}${quote}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Writer {
|
|
61
|
+
write(text: string): void;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Process I/O. Writes are synchronous so nothing is lost when the process is
|
|
66
|
+
* replaced by execve. Tests swap these members out.
|
|
67
|
+
*/
|
|
68
|
+
export const sys = {
|
|
69
|
+
stdout: { write: (text: string) => void fs.writeSync(1, text) } as Writer,
|
|
70
|
+
stderr: { write: (text: string) => void fs.writeSync(2, text) } as Writer,
|
|
71
|
+
stdin: { read: (): string => fs.readFileSync(0, "utf8") },
|
|
72
|
+
argv0: (): string => process.argv[1] ?? SELF,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function print(text: string, stream: Writer = sys.stdout): void {
|
|
76
|
+
stream.write(`${text}\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function env(): Record<string, string> {
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RunResult {
|
|
86
|
+
returncode: number;
|
|
87
|
+
stderr: Uint8Array;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Calls out to the system. Tests swap these members out. */
|
|
91
|
+
export const os = {
|
|
92
|
+
which: (command: string): string | null => Bun.which(command, { PATH: process.env.PATH ?? "" }),
|
|
93
|
+
|
|
94
|
+
/** Run with `input` on stdin, stdout discarded, stderr captured. Throws ENOENT if missing. */
|
|
95
|
+
run: (argv: string[], input: Uint8Array): RunResult => {
|
|
96
|
+
const result = Bun.spawnSync(argv, { stdin: input, stdout: "ignore", stderr: "pipe", env: env() });
|
|
97
|
+
return { returncode: result.exitCode ?? 1, stderr: result.stderr ?? new Uint8Array() };
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
/** Run with stdout/stderr discarded and return the exit code. */
|
|
101
|
+
call: (argv: string[]): number =>
|
|
102
|
+
Bun.spawnSync(argv, { stdin: "ignore", stdout: "ignore", stderr: "ignore", env: env() }).exitCode ?? 1,
|
|
103
|
+
|
|
104
|
+
sleep: (seconds: number): void => Bun.sleepSync(seconds * 1000),
|
|
105
|
+
|
|
106
|
+
urlopen: (url: string, headers: Record<string, string>, timeout: number): Promise<Response> =>
|
|
107
|
+
fetch(url, { headers, signal: AbortSignal.timeout(timeout * 1000) }),
|
|
108
|
+
|
|
109
|
+
/** Replace this process with `file` looked up on PATH. Only returns by throwing. */
|
|
110
|
+
execvp: (file: string, argv: string[]): never => {
|
|
111
|
+
const resolved = file.includes("/") ? file : os.which(file);
|
|
112
|
+
if (!resolved) {
|
|
113
|
+
throw Object.assign(new Error(`[Errno 2] No such file or directory: ${repr(file)}`), { code: "ENOENT" });
|
|
114
|
+
}
|
|
115
|
+
const code = execve(resolved, argv, env());
|
|
116
|
+
throw new Error(`[Errno ${code}] ${strerror(code)}: ${repr(file)}`);
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Build the usage help string lazily from the current invocation name, so the
|
|
122
|
+
* `Usage:` line reflects how `ce` was actually invoked.
|
|
123
|
+
*/
|
|
124
|
+
export function helpText(): string {
|
|
125
|
+
return `Usage: ${sys.argv0()} [--tag-prefix=<prefix>] [--cache-prefix=<prefix>] [command|engine] [args]
|
|
126
|
+
|
|
127
|
+
Options:
|
|
128
|
+
--tag-prefix=<prefix>
|
|
129
|
+
Prepend <prefix> to every tag passed via -t/--tag.
|
|
130
|
+
A trailing "-" is appended automatically if the prefix
|
|
131
|
+
does not already end with "-", "_", or ".".
|
|
132
|
+
Also available via TAG_PREFIX environment variable.
|
|
133
|
+
--cache-prefix=<prefix>
|
|
134
|
+
Prepend <prefix> to every gha cache scope passed via
|
|
135
|
+
--cache-from/--cache-to. Defaults to the tag prefix
|
|
136
|
+
(TAG_PREFIX); set to "" to keep cache scopes shared.
|
|
137
|
+
Also available via CACHE_PREFIX environment variable.
|
|
138
|
+
--cache=<spec> Shorthand for passing <spec> to both --cache-from and
|
|
139
|
+
--cache-to. Supports the same forms as --cache-from/--cache-to.
|
|
140
|
+
|
|
141
|
+
Commands:
|
|
142
|
+
login Log in to all available engines
|
|
143
|
+
|
|
144
|
+
Engines:
|
|
145
|
+
docker [args] Passthrough to docker
|
|
146
|
+
podman [args] Passthrough to podman (via gosu if available)
|
|
147
|
+
buildah [args] Passthrough to buildah (via gosu if available)
|
|
148
|
+
|
|
149
|
+
Environment variables (used by login):
|
|
150
|
+
Each registry is a "group". The default group uses the bare variables
|
|
151
|
+
below; additional registries use a __<name> suffix that ties the three
|
|
152
|
+
variables together.
|
|
153
|
+
REGISTRY_USER[__<name>] username
|
|
154
|
+
REGISTRY_PASSWORD[__<name>] password
|
|
155
|
+
REGISTRY_HOST[__<name>] registry host(s) (https:// stripped);
|
|
156
|
+
whitespace-separated list logs in to each
|
|
157
|
+
with the same creds, e.g. "docker.io dhi.io"
|
|
158
|
+
AUD Forgejo Authorized Integration audience.
|
|
159
|
+
When set, the registry matching the Forgejo
|
|
160
|
+
Actions token-request host uses a short-lived
|
|
161
|
+
OIDC JWT instead of REGISTRY_PASSWORD.
|
|
162
|
+
Docker uses a per-host credential helper that
|
|
163
|
+
refreshes the JWT five minutes before expiry.
|
|
164
|
+
Requires ACTIONS_ID_TOKEN_REQUEST_URL and
|
|
165
|
+
ACTIONS_ID_TOKEN_REQUEST_TOKEN from a job
|
|
166
|
+
with enable-openid-connect: true.
|
|
167
|
+
Defining REGISTRY_HOST__<name> adds a group; its creds come from
|
|
168
|
+
REGISTRY_USER__<name> and REGISTRY_PASSWORD__<name>. Example:
|
|
169
|
+
REGISTRY_HOST__DOCKER="docker.io dhi.io" + REGISTRY_USER__DOCKER +
|
|
170
|
+
REGISTRY_PASSWORD__DOCKER logs in to both Docker Hub and dhi.io with one
|
|
171
|
+
set of creds, alongside the default registry. The bare (unsuffixed)
|
|
172
|
+
variables are the default group — no suffix needed for the primary
|
|
173
|
+
registry.
|
|
174
|
+
`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const INVALID_TAG_CHARS = /[^A-Za-z0-9_.\-]/gu;
|
|
178
|
+
export const INVALID_SCOPE_CHARS = /[^A-Za-z0-9_.:/\-]/gu;
|
|
179
|
+
|
|
180
|
+
// Rewrite "<path>/renovate_<suffix>" to "<path>/renovate:<suffix>" so the
|
|
181
|
+
// dependency/version lands on the tag side under a renovate repo namespace.
|
|
182
|
+
const RENOVATE_RE = /^(.*)\/renovate_(.*)$/u;
|
|
183
|
+
|
|
184
|
+
// Matches a gha cache value of the form `type=gha[,mode=max][,scope=<rest>]`.
|
|
185
|
+
// Group 1 is ",mode=max" when mode=max is present. Group 2 is the raw scope
|
|
186
|
+
// (possibly empty) when scope= is present. A bare `type=gha` or
|
|
187
|
+
// `type=gha,mode=max` (no scope=) is matched and treated as an empty scope;
|
|
188
|
+
// buildx would otherwise default it to the shared "buildkit" scope, bypassing
|
|
189
|
+
// the cache prefix namespacing.
|
|
190
|
+
const GHA_RE = /^type=gha(,mode=max)?(?:,scope=(.*))?$/u;
|
|
191
|
+
|
|
192
|
+
export function sanitizeChars(value: string, charRegex: RegExp): string {
|
|
193
|
+
return value.replace(charRegex, "_");
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Reject non-ASCII characters rather than silently replacing them.
|
|
198
|
+
*
|
|
199
|
+
* ASCII characters outside the safe set are still sanitized to '_' (that is
|
|
200
|
+
* the documented branch-name -> tag contract), but a non-ASCII character would
|
|
201
|
+
* silently change the published image name, so we fail loud and name the
|
|
202
|
+
* offending character instead of mangling it.
|
|
203
|
+
*/
|
|
204
|
+
export function assertAscii(label: string, value: string): void {
|
|
205
|
+
for (const ch of value) {
|
|
206
|
+
const code = ch.codePointAt(0)!;
|
|
207
|
+
if (code >= 0x80) {
|
|
208
|
+
const hex = code.toString(16).toUpperCase().padStart(4, "0");
|
|
209
|
+
throw new ValueError(
|
|
210
|
+
`ce: refusing to sanitize non-ASCII character ${repr(ch)} ` +
|
|
211
|
+
`(U+${hex}) in ${label} ${repr(value)} — rename the ref ` +
|
|
212
|
+
`or pass an explicit ASCII tag.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function stripProto(value: string): string {
|
|
219
|
+
for (const proto of ["https://", "http://"]) {
|
|
220
|
+
if (value.startsWith(proto)) return value.slice(proto.length);
|
|
221
|
+
}
|
|
222
|
+
return value;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** str.partition(sep): split at the first `sep`. */
|
|
226
|
+
function partition(value: string, sep: string): [string, string] {
|
|
227
|
+
const i = value.indexOf(sep);
|
|
228
|
+
return i < 0 ? [value, ""] : [value.slice(0, i), value.slice(i + sep.length)];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Sanitize a tag reference.
|
|
233
|
+
*
|
|
234
|
+
* Strip a leading https:// or http://, then in the tag portion (after the
|
|
235
|
+
* first ":") replace any character not in [A-Za-z0-9_.-] with "_". If a "/"
|
|
236
|
+
* appears in the tag, move it (and any subsequent path segments) to the repo
|
|
237
|
+
* side. Finally, rewrite a "<path>/renovate_<suffix>" prefix to
|
|
238
|
+
* "<path>/renovate:<suffix>".
|
|
239
|
+
*
|
|
240
|
+
* e.g. code.izy.sh/templates/preact/renovate_typescript-native-preview-7.x
|
|
241
|
+
* -> code.izy.sh/templates/preact/renovate:typescript-native-preview-7.x
|
|
242
|
+
*/
|
|
243
|
+
export function sanitizeTag(value: string): string {
|
|
244
|
+
assertAscii("tag", value);
|
|
245
|
+
value = stripProto(value);
|
|
246
|
+
let out: string;
|
|
247
|
+
if (value.includes(":")) {
|
|
248
|
+
const [repo, tag] = partition(value, ":");
|
|
249
|
+
if (tag.includes("/")) {
|
|
250
|
+
const [first, rest] = partition(tag, "/");
|
|
251
|
+
out = `${repo}/${sanitizeChars(first, INVALID_TAG_CHARS)}:${sanitizeChars(rest, INVALID_TAG_CHARS)}`;
|
|
252
|
+
} else {
|
|
253
|
+
out = `${repo}:${sanitizeChars(tag, INVALID_TAG_CHARS)}`;
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
out = value;
|
|
257
|
+
}
|
|
258
|
+
const m = RENOVATE_RE.exec(out);
|
|
259
|
+
if (m) out = `${m[1]}/renovate:${m[2]}`;
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Sanitize a prefix value, appending a "-" separator unless it already ends
|
|
265
|
+
* with one of [-_.]. Empty input yields empty output.
|
|
266
|
+
*/
|
|
267
|
+
export function computeSafePrefix(prefix: string): string {
|
|
268
|
+
assertAscii("tag prefix", prefix);
|
|
269
|
+
let p = sanitizeChars(prefix, INVALID_TAG_CHARS);
|
|
270
|
+
if (p && !"-_.".includes(p.at(-1)!)) p += "-";
|
|
271
|
+
return p;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Extract the scope name from a gha cache value, ignoring type/mode wrappers.
|
|
276
|
+
* Non-gha values are returned unchanged. A bare gha value with no scope=
|
|
277
|
+
* yields an empty scope name so it can be matched against a namespaced
|
|
278
|
+
* cache-to. The scope name is sanitized to a safe character set; ":" and "/"
|
|
279
|
+
* are kept because they appear in common scope conventions.
|
|
280
|
+
*/
|
|
281
|
+
export function cacheScopeName(value: string): string {
|
|
282
|
+
const m = GHA_RE.exec(value);
|
|
283
|
+
if (!m) return value;
|
|
284
|
+
const scope = m[2];
|
|
285
|
+
if (scope === undefined) return "";
|
|
286
|
+
assertAscii("cache scope", scope);
|
|
287
|
+
return sanitizeChars(scope, INVALID_SCOPE_CHARS);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Prepend the sanitized cache prefix to gha cache scope values.
|
|
292
|
+
*
|
|
293
|
+
* A bare gha value with no scope= is treated as an empty scope so the prefix
|
|
294
|
+
* is used as the scope, preventing the buildx default "buildkit" scope from
|
|
295
|
+
* leaking through. If the scope value is empty, the prefix is used alone with
|
|
296
|
+
* no trailing separator. If the prefix does not end with one of [-_.], a "-"
|
|
297
|
+
* is inserted between prefix and scope. Non-gha values are returned unchanged.
|
|
298
|
+
*/
|
|
299
|
+
export function normalizeCacheScope(value: string, cachePrefix: string): string {
|
|
300
|
+
const m = GHA_RE.exec(value);
|
|
301
|
+
if (!m) return value;
|
|
302
|
+
|
|
303
|
+
// Nothing to namespace onto: leave the value (and any buildx-default
|
|
304
|
+
// scope) untouched.
|
|
305
|
+
if (!cachePrefix) return value;
|
|
306
|
+
|
|
307
|
+
assertAscii("cache prefix", cachePrefix);
|
|
308
|
+
const prefix = sanitizeChars(cachePrefix, INVALID_TAG_CHARS);
|
|
309
|
+
// Drop any further ",..." wrapper suffix the scope may carry.
|
|
310
|
+
let scope = partition(cacheScopeName(value), ",")[0];
|
|
311
|
+
|
|
312
|
+
if (!scope) {
|
|
313
|
+
scope = prefix.replace(/[-_.]+$/u, "");
|
|
314
|
+
} else if (prefix) {
|
|
315
|
+
scope = "-_.".includes(prefix.at(-1)!) ? `${prefix}${scope}` : `${prefix}-${scope}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return m[1] ? `type=gha,mode=max,scope=${scope}` : `type=gha,scope=${scope}`;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Wait for an engine to be ready. Returns true once any engine responds. */
|
|
322
|
+
export function doWait(engines: string[]): boolean {
|
|
323
|
+
for (let i = 1; i <= 30; i++) {
|
|
324
|
+
for (const engine of engines) {
|
|
325
|
+
// buildah has no daemon — always considered ready.
|
|
326
|
+
if (engine === "buildah") return true;
|
|
327
|
+
if (os.call([engine, "info"]) === 0) return true;
|
|
328
|
+
}
|
|
329
|
+
print(`waiting for ${engines.join(" ")}... (${i}/30)`, sys.stderr);
|
|
330
|
+
os.sleep(1);
|
|
331
|
+
}
|
|
332
|
+
print(`no engine available: ${engines.join(" ")}`, sys.stderr);
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
interface Authority {
|
|
337
|
+
scheme: string;
|
|
338
|
+
hostname: string | null;
|
|
339
|
+
port: number | null;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Split "[scheme://]host[:port][/path]" into its parts, the way urlsplit does
|
|
344
|
+
* for the registry and token-request values `ce` handles. The hostname is
|
|
345
|
+
* lowercased and IPv6 brackets are removed.
|
|
346
|
+
*/
|
|
347
|
+
function splitAuthority(raw: string): Authority {
|
|
348
|
+
let scheme = "";
|
|
349
|
+
let rest = raw;
|
|
350
|
+
const sep = raw.indexOf("://");
|
|
351
|
+
if (sep >= 0) {
|
|
352
|
+
scheme = raw.slice(0, sep).toLowerCase();
|
|
353
|
+
rest = raw.slice(sep + 3);
|
|
354
|
+
}
|
|
355
|
+
let netloc = rest.split(/[/?#]/u, 1)[0]!;
|
|
356
|
+
netloc = netloc.slice(netloc.lastIndexOf("@") + 1);
|
|
357
|
+
let host = netloc;
|
|
358
|
+
let portText: string | null = null;
|
|
359
|
+
if (netloc.startsWith("[")) {
|
|
360
|
+
const end = netloc.indexOf("]");
|
|
361
|
+
if (end < 0) throw new ValueError("Invalid IPv6 URL");
|
|
362
|
+
host = netloc.slice(1, end);
|
|
363
|
+
const after = netloc.slice(end + 1);
|
|
364
|
+
if (after.startsWith(":")) portText = after.slice(1);
|
|
365
|
+
} else if (netloc.includes(":")) {
|
|
366
|
+
[host, portText] = [netloc.slice(0, netloc.lastIndexOf(":")), netloc.slice(netloc.lastIndexOf(":") + 1)];
|
|
367
|
+
}
|
|
368
|
+
let port: number | null = null;
|
|
369
|
+
if (portText) {
|
|
370
|
+
port = /^\d+$/u.test(portText) ? Number(portText) : NaN;
|
|
371
|
+
if (!(port >= 0 && port <= 65535)) {
|
|
372
|
+
throw new ValueError(`Port could not be cast to integer value as ${repr(portText)}`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return { scheme, hostname: host ? host.toLowerCase() : null, port };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Return a normalized [hostname, effectivePort] for a URL/host.
|
|
380
|
+
*
|
|
381
|
+
* Registry entries commonly omit their scheme while Forgejo's Actions token
|
|
382
|
+
* request URL includes one and may include a path. Comparing normalized
|
|
383
|
+
* authorities lets those forms match without treating a service on another
|
|
384
|
+
* port as the same registry and leaking the JWT to it.
|
|
385
|
+
*/
|
|
386
|
+
export function urlAuthority(value: string, defaultScheme = "https"): [string, number] {
|
|
387
|
+
const raw = value.trim();
|
|
388
|
+
if (!raw) throw new ValueError("empty URL or host");
|
|
389
|
+
|
|
390
|
+
const parsed = splitAuthority(raw);
|
|
391
|
+
const scheme = parsed.scheme || defaultScheme;
|
|
392
|
+
if (scheme !== "http" && scheme !== "https") {
|
|
393
|
+
throw new ValueError(`unsupported URL scheme ${repr(scheme)}`);
|
|
394
|
+
}
|
|
395
|
+
if (!parsed.hostname) throw new ValueError(`missing hostname in ${repr(value)}`);
|
|
396
|
+
let port = parsed.port;
|
|
397
|
+
if (port === null) port = scheme === "http" ? 80 : 443;
|
|
398
|
+
return [parsed.hostname, port];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Validate that `value` is an absolute HTTP(S) URL with a hostname. */
|
|
402
|
+
function assertAbsoluteHttpUrl(value: string, error: string): void {
|
|
403
|
+
if (!value.includes("://")) throw new ValueError(error);
|
|
404
|
+
const parsed = splitAuthority(value);
|
|
405
|
+
if ((parsed.scheme !== "http" && parsed.scheme !== "https") || !parsed.hostname) {
|
|
406
|
+
throw new ValueError(error);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Fetch a Forgejo Actions OIDC JWT for an Authorized Integration. */
|
|
411
|
+
export async function forgejoOidcJwt(audience: string, requestUrl: string, requestToken: string): Promise<string> {
|
|
412
|
+
assertAbsoluteHttpUrl(requestUrl, "token request URL must be an absolute HTTP(S) URL");
|
|
413
|
+
|
|
414
|
+
let tokenUrl: string;
|
|
415
|
+
let headers: Record<string, string>;
|
|
416
|
+
try {
|
|
417
|
+
const url = new URL(requestUrl);
|
|
418
|
+
// Replace, rather than duplicate, an audience supplied by a caller.
|
|
419
|
+
url.searchParams.delete("audience");
|
|
420
|
+
url.searchParams.append("audience", audience);
|
|
421
|
+
tokenUrl = url.href;
|
|
422
|
+
headers = { Accept: "application/json", Authorization: `bearer ${requestToken}` };
|
|
423
|
+
new Headers(headers);
|
|
424
|
+
} catch {
|
|
425
|
+
// Some URL/header errors include their raw input in the exception.
|
|
426
|
+
// Avoid reflecting the request credential into workflow logs.
|
|
427
|
+
throw new ValueError("token request URL or headers are invalid");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let body: Uint8Array;
|
|
431
|
+
try {
|
|
432
|
+
const response = await os.urlopen(tokenUrl, headers, 30);
|
|
433
|
+
if (!response.ok) throw new Error(`HTTP Error ${response.status}: ${response.statusText}`);
|
|
434
|
+
body = new Uint8Array(await response.arrayBuffer());
|
|
435
|
+
} catch (exc) {
|
|
436
|
+
throw new ValueError(`token request failed: ${message(exc)}`);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
let text: string;
|
|
440
|
+
try {
|
|
441
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(body);
|
|
442
|
+
} catch {
|
|
443
|
+
throw new ValueError("token response is not valid UTF-8 JSON");
|
|
444
|
+
}
|
|
445
|
+
let payload: unknown;
|
|
446
|
+
try {
|
|
447
|
+
payload = JSON.parse(text);
|
|
448
|
+
} catch {
|
|
449
|
+
throw new ValueError("token response is not valid JSON");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (!isObject(payload)) throw new ValueError("token response is not a JSON object");
|
|
453
|
+
const jwt = payload.value;
|
|
454
|
+
if (typeof jwt !== "string" || !jwt.trim()) {
|
|
455
|
+
throw new ValueError("token response has no non-empty string 'value'");
|
|
456
|
+
}
|
|
457
|
+
return jwt.trim();
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
461
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** Read the numeric `exp` claim without treating it as validation. */
|
|
465
|
+
export function jwtExpiry(jwt: string): number {
|
|
466
|
+
const parts = jwt.split(".");
|
|
467
|
+
if (parts.length !== 3) throw new ValueError("OIDC token is not a three-part JWT");
|
|
468
|
+
let payload: unknown;
|
|
469
|
+
try {
|
|
470
|
+
const bytes = Buffer.from(parts[1]!, "base64url");
|
|
471
|
+
payload = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
472
|
+
} catch {
|
|
473
|
+
throw new ValueError("OIDC token has an invalid JWT payload");
|
|
474
|
+
}
|
|
475
|
+
const expiry = isObject(payload) ? payload.exp : undefined;
|
|
476
|
+
if (typeof expiry !== "number") throw new ValueError("OIDC token has no numeric 'exp' claim");
|
|
477
|
+
return expiry;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* The directory holding the installed `ce` and `docker-credential-ce-forgejo`
|
|
482
|
+
* executables (the package's bin/), wherever bunx or an install put it.
|
|
483
|
+
*/
|
|
484
|
+
export function scriptDir(): string {
|
|
485
|
+
return fs.realpathSync(path.join(import.meta.dir, "..", "bin"));
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Make the installed script directory visible to Docker. */
|
|
489
|
+
export function ensureHelperOnPath(): void {
|
|
490
|
+
const dir = scriptDir();
|
|
491
|
+
const parts = (process.env.PATH ?? "").split(path.delimiter);
|
|
492
|
+
if (!parts.includes(dir)) process.env.PATH = [dir, ...parts].join(path.delimiter);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function expandUser(value: string): string {
|
|
496
|
+
if (value === "~" || value.startsWith("~/")) return path.join(homeDir(), value.slice(1));
|
|
497
|
+
return value;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function homeDir(): string {
|
|
501
|
+
return homedir();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function dockerConfigDir(): string {
|
|
505
|
+
const configured = process.env.DOCKER_CONFIG;
|
|
506
|
+
if (configured) return path.resolve(expandUser(configured));
|
|
507
|
+
return path.join(homeDir(), ".docker");
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
export function forgejoStatePath(): string {
|
|
511
|
+
return path.join(dockerConfigDir(), FORGEJO_STATE_FILE);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** Return the Docker registry server name, excluding scheme and path. */
|
|
515
|
+
export function registryServer(value: string): string {
|
|
516
|
+
const parsed = splitAuthority(value.trim());
|
|
517
|
+
if (!parsed.hostname) throw new ValueError(`missing hostname in ${repr(value)}`);
|
|
518
|
+
let hostname = parsed.hostname;
|
|
519
|
+
if (hostname.includes(":")) hostname = `[${hostname}]`;
|
|
520
|
+
return parsed.port !== null ? `${hostname}:${parsed.port}` : hostname;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function registryKey(value: string): string {
|
|
524
|
+
return registryServer(value).toLowerCase();
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
export function readJsonFile(file: string, fallback: unknown): unknown {
|
|
528
|
+
try {
|
|
529
|
+
return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(fs.readFileSync(file)));
|
|
530
|
+
} catch (exc) {
|
|
531
|
+
if (isOSError(exc) && exc.code === "ENOENT") return fallback;
|
|
532
|
+
throw new ValueError(`cannot read JSON file ${repr(file)}: ${message(exc)}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** JSON with sorted keys and compact separators, as `ce` has always written it. */
|
|
537
|
+
function dumpJson(payload: unknown): string {
|
|
538
|
+
return JSON.stringify(payload, (_key, value: unknown) =>
|
|
539
|
+
isObject(value) ? Object.fromEntries(Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) : value,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function writePrivateJson(file: string, payload: unknown): void {
|
|
544
|
+
const directory = path.dirname(file);
|
|
545
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
546
|
+
const temporary = path.join(directory, `.ce-forgejo-${crypto.randomUUID().slice(0, 8)}`);
|
|
547
|
+
const fd = fs.openSync(temporary, "wx", 0o600);
|
|
548
|
+
try {
|
|
549
|
+
try {
|
|
550
|
+
fs.fchmodSync(fd, 0o600);
|
|
551
|
+
fs.writeSync(fd, `${dumpJson(payload)}\n`);
|
|
552
|
+
fs.fsyncSync(fd);
|
|
553
|
+
} finally {
|
|
554
|
+
fs.closeSync(fd);
|
|
555
|
+
}
|
|
556
|
+
fs.renameSync(temporary, file);
|
|
557
|
+
} finally {
|
|
558
|
+
fs.rmSync(temporary, { force: true });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
interface HelperState {
|
|
563
|
+
version?: number;
|
|
564
|
+
registries: Record<string, Record<string, unknown>>;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
type LockCallback<T> = (state: HelperState) => [T, boolean] | Promise<[T, boolean]>;
|
|
568
|
+
|
|
569
|
+
/** Run `callback(state)` under the helper's cross-process lock. */
|
|
570
|
+
export async function withForgejoStateLock<T>(callback: LockCallback<T>): Promise<T> {
|
|
571
|
+
const statePath = forgejoStatePath();
|
|
572
|
+
fs.mkdirSync(path.dirname(statePath), { recursive: true, mode: 0o700 });
|
|
573
|
+
const lockPath = `${statePath}.lock`;
|
|
574
|
+
const lock = fs.openSync(lockPath, "a+");
|
|
575
|
+
try {
|
|
576
|
+
fs.chmodSync(lockPath, 0o600);
|
|
577
|
+
const code = flock(lock, LOCK_EX);
|
|
578
|
+
if (code !== 0) {
|
|
579
|
+
throw Object.assign(new Error(`[Errno ${code}] ${strerror(code)}: ${repr(lockPath)}`), { code: "EFLOCK" });
|
|
580
|
+
}
|
|
581
|
+
const state = readJsonFile(statePath, { version: 1, registries: {} });
|
|
582
|
+
if (!isObject(state) || !isObject(state.registries)) {
|
|
583
|
+
throw new ValueError("Forgejo credential-helper state is invalid");
|
|
584
|
+
}
|
|
585
|
+
const [result, changed] = await callback(state as unknown as HelperState);
|
|
586
|
+
if (changed) writePrivateJson(statePath, state);
|
|
587
|
+
return result;
|
|
588
|
+
} finally {
|
|
589
|
+
fs.closeSync(lock);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
export async function saveForgejoHelperState(
|
|
594
|
+
registry: string,
|
|
595
|
+
username: string,
|
|
596
|
+
audience: string,
|
|
597
|
+
authority: [string, number],
|
|
598
|
+
jwt: string,
|
|
599
|
+
): Promise<void> {
|
|
600
|
+
const expiry = jwtExpiry(jwt);
|
|
601
|
+
if (expiry <= Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
|
|
602
|
+
throw new ValueError("OIDC token expires within the five-minute refresh window");
|
|
603
|
+
}
|
|
604
|
+
await withForgejoStateLock((state) => {
|
|
605
|
+
state.registries[registryKey(registry)] = {
|
|
606
|
+
audience,
|
|
607
|
+
authority: [authority[0], authority[1]],
|
|
608
|
+
expires_at: expiry,
|
|
609
|
+
token: jwt,
|
|
610
|
+
username,
|
|
611
|
+
};
|
|
612
|
+
return [undefined, true];
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Atomically register the per-host helper after a successful login. */
|
|
617
|
+
export async function configureDockerCredentialHelper(
|
|
618
|
+
registry: string,
|
|
619
|
+
username: string,
|
|
620
|
+
audience: string,
|
|
621
|
+
authority: [string, number],
|
|
622
|
+
jwt: string,
|
|
623
|
+
): Promise<void> {
|
|
624
|
+
await saveForgejoHelperState(registry, username, audience, authority, jwt);
|
|
625
|
+
|
|
626
|
+
const configPath = path.join(dockerConfigDir(), "config.json");
|
|
627
|
+
const config = readJsonFile(configPath, {});
|
|
628
|
+
if (!isObject(config)) throw new ValueError("Docker config is not a JSON object");
|
|
629
|
+
config.credHelpers ??= {};
|
|
630
|
+
const helpers = config.credHelpers;
|
|
631
|
+
if (!isObject(helpers)) throw new ValueError("Docker config 'credHelpers' is not a JSON object");
|
|
632
|
+
helpers[registry] = FORGEJO_HELPER_SUFFIX;
|
|
633
|
+
|
|
634
|
+
// A helper takes precedence, but remove the now-stale plaintext copy so the
|
|
635
|
+
// JWT has one source of truth and cannot be used after helper-state expiry.
|
|
636
|
+
const auths = config.auths;
|
|
637
|
+
if (isObject(auths)) {
|
|
638
|
+
for (const key of Object.keys(auths)) {
|
|
639
|
+
if (registryKey(key) === registryKey(registry)) delete auths[key];
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
writePrivateJson(configPath, config);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export async function helperGet(registry: string): Promise<{ Username: string; Secret: string }> {
|
|
646
|
+
const key = registryKey(registry);
|
|
647
|
+
|
|
648
|
+
return withForgejoStateLock(async (state) => {
|
|
649
|
+
const entry = state.registries[key];
|
|
650
|
+
if (!isObject(entry)) throw new ValueError(`no Forgejo OIDC credentials configured for ${repr(registry)}`);
|
|
651
|
+
const { username, token, expires_at: expiry } = entry;
|
|
652
|
+
if (typeof username !== "string" || !username) {
|
|
653
|
+
throw new ValueError("Forgejo credential-helper username is missing");
|
|
654
|
+
}
|
|
655
|
+
if (typeof token !== "string" || typeof expiry !== "number") {
|
|
656
|
+
throw new ValueError("Forgejo credential-helper token state is invalid");
|
|
657
|
+
}
|
|
658
|
+
if (expiry > Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
|
|
659
|
+
return [{ Username: username, Secret: token }, false];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
const { audience, authority: storedAuthority } = entry;
|
|
663
|
+
const requestUrl = process.env.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
|
|
664
|
+
const requestToken = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
|
|
665
|
+
if (typeof audience !== "string" || !audience) {
|
|
666
|
+
throw new ValueError("Forgejo credential-helper audience is missing");
|
|
667
|
+
}
|
|
668
|
+
if (!(requestUrl && requestToken)) {
|
|
669
|
+
throw new ValueError("OIDC refresh requires ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN");
|
|
670
|
+
}
|
|
671
|
+
const [host, port] = urlAuthority(requestUrl);
|
|
672
|
+
if (!Array.isArray(storedAuthority) || storedAuthority.length !== 2 || storedAuthority[0] !== host || storedAuthority[1] !== port) {
|
|
673
|
+
throw new ValueError("OIDC token-request host does not match the registry");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
const refreshed = await forgejoOidcJwt(audience, requestUrl, requestToken);
|
|
677
|
+
const refreshedExpiry = jwtExpiry(refreshed);
|
|
678
|
+
if (refreshedExpiry <= Date.now() / 1000 + FORGEJO_REFRESH_WINDOW) {
|
|
679
|
+
throw new ValueError("refreshed OIDC token expires within the five-minute window");
|
|
680
|
+
}
|
|
681
|
+
entry.token = refreshed;
|
|
682
|
+
entry.expires_at = refreshedExpiry;
|
|
683
|
+
return [{ Username: username, Secret: refreshed }, true];
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Implement Docker's credential-helper protocol for Forgejo OIDC. */
|
|
688
|
+
export async function cmdCredentialHelper(argv: string[]): Promise<number> {
|
|
689
|
+
const command = argv[0];
|
|
690
|
+
if (argv.length !== 1 || !["get", "store", "erase", "list"].includes(command!)) {
|
|
691
|
+
print("ce: credential-helper requires get|store|erase|list", sys.stderr);
|
|
692
|
+
return 1;
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
if (command === "get") {
|
|
696
|
+
const result = await helperGet(sys.stdin.read().trim());
|
|
697
|
+
sys.stdout.write(`${JSON.stringify(result)}\n`);
|
|
698
|
+
return 0;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (command === "store") {
|
|
702
|
+
const payload: unknown = JSON.parse(sys.stdin.read());
|
|
703
|
+
if (!isObject(payload)) throw new ValueError("credential-helper store payload is invalid");
|
|
704
|
+
const { ServerURL: registry, Username: username, Secret: secret } = payload;
|
|
705
|
+
if (![registry, username, secret].every((v) => typeof v === "string" && v)) {
|
|
706
|
+
throw new ValueError("credential-helper store fields are incomplete");
|
|
707
|
+
}
|
|
708
|
+
const expiry = jwtExpiry(secret as string);
|
|
709
|
+
await withForgejoStateLock((state) => {
|
|
710
|
+
const entry = (state.registries[registryKey(registry as string)] ??= {});
|
|
711
|
+
Object.assign(entry, { expires_at: expiry, token: secret, username });
|
|
712
|
+
return [undefined, true];
|
|
713
|
+
});
|
|
714
|
+
return 0;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
if (command === "erase") {
|
|
718
|
+
const registry = sys.stdin.read().trim();
|
|
719
|
+
await withForgejoStateLock((state) => {
|
|
720
|
+
const key = registryKey(registry);
|
|
721
|
+
const removed = key in state.registries;
|
|
722
|
+
delete state.registries[key];
|
|
723
|
+
return [undefined, removed];
|
|
724
|
+
});
|
|
725
|
+
return 0;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
const result = await withForgejoStateLock((state) => {
|
|
729
|
+
const entries: Record<string, unknown> = {};
|
|
730
|
+
for (const [key, value] of Object.entries(state.registries)) {
|
|
731
|
+
if (isObject(value)) entries[key] = value.username ?? "";
|
|
732
|
+
}
|
|
733
|
+
return [entries, false];
|
|
734
|
+
});
|
|
735
|
+
sys.stdout.write(`${JSON.stringify(result)}\n`);
|
|
736
|
+
return 0;
|
|
737
|
+
} catch (exc) {
|
|
738
|
+
if (!(exc instanceof ValueError || exc instanceof SyntaxError || isOSError(exc))) throw exc;
|
|
739
|
+
print(`ce: Forgejo credential helper: ${message(exc)}`, sys.stderr);
|
|
740
|
+
return 1;
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Log in to every available engine for each registry group.
|
|
746
|
+
*
|
|
747
|
+
* Groups are processed in sorted order (default group first, then
|
|
748
|
+
* REGISTRY_HOST__<suffix> groups alphabetically by suffix) so that the order
|
|
749
|
+
* is deterministic and derivable from the variable names. This matters when
|
|
750
|
+
* two groups target the same registry with different credentials: the
|
|
751
|
+
* credential stores are last-write-wins, so the final group wins.
|
|
752
|
+
*/
|
|
753
|
+
export async function cmdLogin(): Promise<number> {
|
|
754
|
+
ensureHelperOnPath();
|
|
755
|
+
const environ = process.env;
|
|
756
|
+
const groups: string[] = [];
|
|
757
|
+
if (environ.REGISTRY_HOST) groups.push("");
|
|
758
|
+
for (const name of Object.keys(environ).sort()) {
|
|
759
|
+
if (name.startsWith("REGISTRY_HOST__")) {
|
|
760
|
+
const suffix = name.slice("REGISTRY_HOST__".length);
|
|
761
|
+
if (suffix) groups.push(suffix);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (groups.length === 0) {
|
|
766
|
+
print("Skipping login: no REGISTRY_HOST env vars set");
|
|
767
|
+
return 0;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
const audience = environ.AUD ?? "";
|
|
771
|
+
let oidcAuthority: [string, number] | null = null;
|
|
772
|
+
let oidcRequestUrl = "";
|
|
773
|
+
let oidcRequestToken = "";
|
|
774
|
+
if (audience) {
|
|
775
|
+
oidcRequestUrl = environ.ACTIONS_ID_TOKEN_REQUEST_URL ?? "";
|
|
776
|
+
oidcRequestToken = environ.ACTIONS_ID_TOKEN_REQUEST_TOKEN ?? "";
|
|
777
|
+
if (!(oidcRequestUrl && oidcRequestToken)) {
|
|
778
|
+
print(
|
|
779
|
+
"Forgejo OIDC login config is incomplete: AUD requires " +
|
|
780
|
+
"ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN",
|
|
781
|
+
sys.stderr,
|
|
782
|
+
);
|
|
783
|
+
return 1;
|
|
784
|
+
}
|
|
785
|
+
try {
|
|
786
|
+
assertAbsoluteHttpUrl(oidcRequestUrl, "must be an absolute HTTP(S) URL");
|
|
787
|
+
oidcAuthority = urlAuthority(oidcRequestUrl);
|
|
788
|
+
} catch (exc) {
|
|
789
|
+
if (!(exc instanceof ValueError)) throw exc;
|
|
790
|
+
print(`Invalid ACTIONS_ID_TOKEN_REQUEST_URL: ${exc.message}`, sys.stderr);
|
|
791
|
+
return 1;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Resolve and validate every target before logging in. In particular, this
|
|
796
|
+
// lets an OIDC fetch failure stop the operation before any engine receives
|
|
797
|
+
// credentials.
|
|
798
|
+
const targets: { registry: string; label: string; user: string; pwd: string; useOidc: boolean }[] = [];
|
|
799
|
+
let failed = 0;
|
|
800
|
+
for (const group of groups) {
|
|
801
|
+
const suffix = group ? `__${group}` : "";
|
|
802
|
+
const host = environ[`REGISTRY_HOST${suffix}`] ?? "";
|
|
803
|
+
const user = environ[`REGISTRY_USER${suffix}`] ?? "";
|
|
804
|
+
const pwd = environ[`REGISTRY_PASSWORD${suffix}`] ?? "";
|
|
805
|
+
const label = group || "default";
|
|
806
|
+
|
|
807
|
+
if (!(host && user)) {
|
|
808
|
+
print(`Login config for '${label}' is incomplete: host/user/password not all set`, sys.stderr);
|
|
809
|
+
failed = 1;
|
|
810
|
+
continue;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// A group's host may list multiple registries separated by whitespace;
|
|
814
|
+
// log in to each with the same creds.
|
|
815
|
+
for (const rawRegistry of host.split(/\s+/u).filter(Boolean)) {
|
|
816
|
+
let authority: [string, number];
|
|
817
|
+
let registry: string;
|
|
818
|
+
try {
|
|
819
|
+
authority = urlAuthority(rawRegistry);
|
|
820
|
+
registry = registryServer(rawRegistry);
|
|
821
|
+
} catch (exc) {
|
|
822
|
+
if (!(exc instanceof ValueError)) throw exc;
|
|
823
|
+
print(`Invalid registry host for '${label}' (${rawRegistry}): ${exc.message}`, sys.stderr);
|
|
824
|
+
failed = 1;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const useOidc = oidcAuthority !== null && authority[0] === oidcAuthority[0] && authority[1] === oidcAuthority[1];
|
|
828
|
+
if (!useOidc && !pwd) {
|
|
829
|
+
print(`Login config for '${label}' is incomplete: host/user/password not all set`, sys.stderr);
|
|
830
|
+
failed = 1;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
targets.push({ registry, label, user, pwd, useOidc });
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
let oidcJwt: string | null = null;
|
|
838
|
+
if (targets.some((t) => t.useOidc)) {
|
|
839
|
+
try {
|
|
840
|
+
oidcJwt = await forgejoOidcJwt(audience, oidcRequestUrl, oidcRequestToken);
|
|
841
|
+
} catch (exc) {
|
|
842
|
+
if (!(exc instanceof ValueError)) throw exc;
|
|
843
|
+
print(`Failed to fetch Forgejo OIDC JWT: ${exc.message}`, sys.stderr);
|
|
844
|
+
return 1;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
for (const { registry, label, user, pwd, useOidc } of targets) {
|
|
849
|
+
const password = useOidc ? oidcJwt : pwd;
|
|
850
|
+
// oidcJwt is guaranteed above for OIDC targets. Keep this guard so a
|
|
851
|
+
// future refactor cannot accidentally fall back to a static pwd.
|
|
852
|
+
if (password === null) {
|
|
853
|
+
print(`Forgejo OIDC JWT unavailable for '${label}' (${registry})`, sys.stderr);
|
|
854
|
+
failed = 1;
|
|
855
|
+
continue;
|
|
856
|
+
}
|
|
857
|
+
print(`Logging in to ${registry} (${label})...`);
|
|
858
|
+
let loggedIn: string | null = null;
|
|
859
|
+
for (const engine of ["docker", "podman", "buildah"]) {
|
|
860
|
+
if (!os.which(engine)) continue;
|
|
861
|
+
let result: RunResult;
|
|
862
|
+
try {
|
|
863
|
+
result = os.run([engine, "login", "-u", user, "--password-stdin", registry], Buffer.from(`${password}\n`));
|
|
864
|
+
} catch (exc) {
|
|
865
|
+
if (isOSError(exc) && exc.code === "ENOENT") continue;
|
|
866
|
+
throw exc;
|
|
867
|
+
}
|
|
868
|
+
if (result.returncode === 0) {
|
|
869
|
+
loggedIn = engine;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
print(`${engine} login failed for '${label}' (${registry}):`, sys.stderr);
|
|
873
|
+
sys.stderr.write(new TextDecoder().decode(result.stderr));
|
|
874
|
+
}
|
|
875
|
+
if (loggedIn) {
|
|
876
|
+
print(
|
|
877
|
+
`Logged in to ${registry} (${label}) via ${loggedIn} ` +
|
|
878
|
+
`(and all engines at or below it: docker < podman < buildah share credentials)`,
|
|
879
|
+
);
|
|
880
|
+
if (useOidc) {
|
|
881
|
+
try {
|
|
882
|
+
if (oidcAuthority === null || oidcJwt === null) throw new ValueError("OIDC helper state is unavailable");
|
|
883
|
+
await configureDockerCredentialHelper(registry, user, audience, oidcAuthority, oidcJwt);
|
|
884
|
+
} catch (exc) {
|
|
885
|
+
if (!(exc instanceof ValueError || isOSError(exc))) throw exc;
|
|
886
|
+
print(`Failed to configure Forgejo credential helper for '${label}' (${registry}): ${message(exc)}`, sys.stderr);
|
|
887
|
+
failed = 1;
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
print(`Login failed for '${label}' (${registry})`, sys.stderr);
|
|
892
|
+
failed = 1;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
return failed;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Return the rewritten tag refs for a -t/--tag value (no flag prefix).
|
|
900
|
+
*
|
|
901
|
+
* Strips a leading https:// or http://, splits comma/whitespace-separated
|
|
902
|
+
* tags, prepends the sanitized tagPrefix, sanitizes each, and returns the
|
|
903
|
+
* list of refs. Bare refs (no colon) pass through unchanged.
|
|
904
|
+
*/
|
|
905
|
+
export function rewriteTagValue(value: string, tagPrefix: string): string[] {
|
|
906
|
+
const v = stripProto(value);
|
|
907
|
+
const refs: string[] = [];
|
|
908
|
+
if (v.includes(":")) {
|
|
909
|
+
const [repo, tags] = partition(v, ":");
|
|
910
|
+
const safePrefix = computeSafePrefix(tagPrefix);
|
|
911
|
+
// Run each through sanitizeTag so "/" in a tag gets moved to the repo.
|
|
912
|
+
for (const tag of tags.split(/[,\s]+/u)) {
|
|
913
|
+
if (!tag) continue;
|
|
914
|
+
const ref = sanitizeTag(`${repo}:${safePrefix}${tag}`);
|
|
915
|
+
if (ref) refs.push(ref);
|
|
916
|
+
}
|
|
917
|
+
} else {
|
|
918
|
+
assertAscii("tag", v);
|
|
919
|
+
refs.push(v);
|
|
920
|
+
}
|
|
921
|
+
return refs;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Consume the next token of `argv` as the value for `flag`. Errors if `argv`
|
|
926
|
+
* is empty or the next token looks like another flag (starts with '-' and
|
|
927
|
+
* isn't a bare '-'). A bare '-' is allowed (e.g. `docker build -f -`).
|
|
928
|
+
*/
|
|
929
|
+
export function consumeValue(argv: string[], flag: string): [string, string[]] {
|
|
930
|
+
if (argv.length === 0) throw new ValueError(`ce: ${flag} requires a value but none was provided.`);
|
|
931
|
+
const v = argv[0]!;
|
|
932
|
+
if (v.length > 1 && v.startsWith("-")) {
|
|
933
|
+
throw new ValueError(`ce: ${flag} requires a value but the next token ${repr(v)} looks like a flag.`);
|
|
934
|
+
}
|
|
935
|
+
return [v, argv.slice(1)];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** Return [value, nextIndex] for a flag that takes a separate value. */
|
|
939
|
+
export function nextValue(argv: string[], i: number, flag: string): [string, number] {
|
|
940
|
+
return [consumeValue(argv.slice(i + 1), flag)[0], i + 1];
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export interface BuildSummary {
|
|
944
|
+
tags: string[];
|
|
945
|
+
cache_from: string[];
|
|
946
|
+
cache_to: string[];
|
|
947
|
+
build_args: string[];
|
|
948
|
+
dockerfile: string;
|
|
949
|
+
target: string;
|
|
950
|
+
platform: string;
|
|
951
|
+
context: string;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Parse a build/bud argv once into exec args + a summary record.
|
|
956
|
+
*
|
|
957
|
+
* Returns [execArgs, summary] where execArgs is the ordered argument list to
|
|
958
|
+
* pass to the engine (tags inline, cache-from/cache-to appended at the end)
|
|
959
|
+
* and summary holds display fields with already-rewritten tag/cache values.
|
|
960
|
+
* The context is the last non-flag positional.
|
|
961
|
+
*/
|
|
962
|
+
export function parseBuildArgs(argv: string[], tagPrefix: string, cachePrefix: string): [string[], BuildSummary] {
|
|
963
|
+
const execArgs: string[] = [];
|
|
964
|
+
const rawCacheFrom: string[] = [];
|
|
965
|
+
const rawCacheTo: string[] = [];
|
|
966
|
+
const summary: BuildSummary = {
|
|
967
|
+
tags: [],
|
|
968
|
+
cache_from: [],
|
|
969
|
+
cache_to: [],
|
|
970
|
+
build_args: [],
|
|
971
|
+
dockerfile: "",
|
|
972
|
+
target: "",
|
|
973
|
+
platform: "",
|
|
974
|
+
context: "",
|
|
975
|
+
};
|
|
976
|
+
const addTags = (value: string, flag: "-t" | "--tag=") => {
|
|
977
|
+
const refs = rewriteTagValue(value, tagPrefix);
|
|
978
|
+
for (const ref of refs) execArgs.push(`${flag}${ref}`);
|
|
979
|
+
summary.tags.push(...refs);
|
|
980
|
+
};
|
|
981
|
+
|
|
982
|
+
for (let i = 0; i < argv.length; i++) {
|
|
983
|
+
const arg = argv[i]!;
|
|
984
|
+
let v: string;
|
|
985
|
+
if (arg === "-t") {
|
|
986
|
+
[v, i] = nextValue(argv, i, "-t");
|
|
987
|
+
addTags(v, "-t");
|
|
988
|
+
} else if (arg === "--tag") {
|
|
989
|
+
[v, i] = nextValue(argv, i, "--tag");
|
|
990
|
+
addTags(v, "--tag=");
|
|
991
|
+
} else if (arg.startsWith("-t") && arg.length > 2) {
|
|
992
|
+
addTags(arg.slice(2), "-t");
|
|
993
|
+
} else if (arg.startsWith("--tag=")) {
|
|
994
|
+
addTags(arg.slice("--tag=".length), "--tag=");
|
|
995
|
+
} else if (arg === "--cache-from") {
|
|
996
|
+
[v, i] = nextValue(argv, i, "--cache-from");
|
|
997
|
+
rawCacheFrom.push(v);
|
|
998
|
+
} else if (arg === "--cache-to") {
|
|
999
|
+
[v, i] = nextValue(argv, i, "--cache-to");
|
|
1000
|
+
rawCacheTo.push(v);
|
|
1001
|
+
} else if (arg.startsWith("--cache-from=")) {
|
|
1002
|
+
rawCacheFrom.push(arg.slice("--cache-from=".length));
|
|
1003
|
+
} else if (arg.startsWith("--cache-to=")) {
|
|
1004
|
+
rawCacheTo.push(arg.slice("--cache-to=".length));
|
|
1005
|
+
} else if (arg === "--cache") {
|
|
1006
|
+
[v, i] = nextValue(argv, i, "--cache");
|
|
1007
|
+
rawCacheFrom.push(v);
|
|
1008
|
+
rawCacheTo.push(v);
|
|
1009
|
+
} else if (arg.startsWith("--cache=")) {
|
|
1010
|
+
v = arg.slice("--cache=".length);
|
|
1011
|
+
rawCacheFrom.push(v);
|
|
1012
|
+
rawCacheTo.push(v);
|
|
1013
|
+
} else if (arg === "-f" || arg === "--file") {
|
|
1014
|
+
[v, i] = nextValue(argv, i, arg);
|
|
1015
|
+
execArgs.push(arg, v);
|
|
1016
|
+
summary.dockerfile = v;
|
|
1017
|
+
} else if (arg.startsWith("-f") && arg.length > 2) {
|
|
1018
|
+
execArgs.push(arg);
|
|
1019
|
+
summary.dockerfile = arg.slice(2);
|
|
1020
|
+
} else if (arg.startsWith("--file=")) {
|
|
1021
|
+
execArgs.push(arg);
|
|
1022
|
+
summary.dockerfile = arg.slice("--file=".length);
|
|
1023
|
+
} else if (arg === "--target") {
|
|
1024
|
+
[v, i] = nextValue(argv, i, "--target");
|
|
1025
|
+
execArgs.push(arg, v);
|
|
1026
|
+
summary.target = v;
|
|
1027
|
+
} else if (arg.startsWith("--target=")) {
|
|
1028
|
+
execArgs.push(arg);
|
|
1029
|
+
summary.target = arg.slice("--target=".length);
|
|
1030
|
+
} else if (arg === "--platform") {
|
|
1031
|
+
[v, i] = nextValue(argv, i, "--platform");
|
|
1032
|
+
execArgs.push(arg, v);
|
|
1033
|
+
summary.platform = v;
|
|
1034
|
+
} else if (arg.startsWith("--platform=")) {
|
|
1035
|
+
execArgs.push(arg);
|
|
1036
|
+
summary.platform = arg.slice("--platform=".length);
|
|
1037
|
+
} else if (arg === "--build-arg") {
|
|
1038
|
+
[v, i] = nextValue(argv, i, "--build-arg");
|
|
1039
|
+
execArgs.push(arg, v);
|
|
1040
|
+
summary.build_args.push(v);
|
|
1041
|
+
} else if (arg.startsWith("--build-arg=")) {
|
|
1042
|
+
execArgs.push(arg);
|
|
1043
|
+
summary.build_args.push(arg.slice("--build-arg=".length));
|
|
1044
|
+
} else if (arg.startsWith("-")) {
|
|
1045
|
+
execArgs.push(arg);
|
|
1046
|
+
} else {
|
|
1047
|
+
execArgs.push(arg);
|
|
1048
|
+
summary.context = arg; // last non-flag positional wins
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Scopes matching a cache-to get the cache prefix applied so reads and
|
|
1053
|
+
// writes target the same namespaced scope. Unmatched cache-from values pass
|
|
1054
|
+
// through unchanged so they can read from a shared cache.
|
|
1055
|
+
const toNames = new Set(rawCacheTo.map(cacheScopeName));
|
|
1056
|
+
for (const c of rawCacheFrom) {
|
|
1057
|
+
const rewritten = toNames.has(cacheScopeName(c)) ? normalizeCacheScope(c, cachePrefix) : c;
|
|
1058
|
+
execArgs.push(`--cache-from=${rewritten}`);
|
|
1059
|
+
summary.cache_from.push(rewritten);
|
|
1060
|
+
}
|
|
1061
|
+
for (const c of rawCacheTo) {
|
|
1062
|
+
const rewritten = normalizeCacheScope(c, cachePrefix);
|
|
1063
|
+
execArgs.push(`--cache-to=${rewritten}`);
|
|
1064
|
+
summary.cache_to.push(rewritten);
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
return [execArgs, summary];
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/** Print a build/bud summary to stderr. Only for build-like subcommands. */
|
|
1071
|
+
export function formatBuildSummary(engine: string, execArgs: string[], summary: BuildSummary): void {
|
|
1072
|
+
// Skip leading flags and an optional "buildx" to find a build|bud
|
|
1073
|
+
// subcommand; do not summarize other subcommands (e.g. push, pull).
|
|
1074
|
+
let subcommand = "";
|
|
1075
|
+
for (const arg of execArgs) {
|
|
1076
|
+
if (arg.startsWith("-") || arg === "buildx") continue;
|
|
1077
|
+
if (arg === "build" || arg === "bud") subcommand = arg;
|
|
1078
|
+
break;
|
|
1079
|
+
}
|
|
1080
|
+
if (!subcommand) return;
|
|
1081
|
+
|
|
1082
|
+
const out = [`[${SELF}] build summary:`, ` engine: ${engine}`, ` subcommand: ${subcommand}`];
|
|
1083
|
+
const list = (title: string, items: string[]) => {
|
|
1084
|
+
if (items.length) out.push(` ${title}:`, ...items.map((item) => ` - ${item}`));
|
|
1085
|
+
};
|
|
1086
|
+
list("tags", summary.tags);
|
|
1087
|
+
list("cache-from", summary.cache_from);
|
|
1088
|
+
list("cache-to", summary.cache_to);
|
|
1089
|
+
if (summary.dockerfile) out.push(` dockerfile: ${summary.dockerfile}`);
|
|
1090
|
+
if (summary.target) out.push(` target: ${summary.target}`);
|
|
1091
|
+
if (summary.platform) out.push(` platform: ${summary.platform}`);
|
|
1092
|
+
list("build args", summary.build_args);
|
|
1093
|
+
if (summary.context) out.push(` context: ${summary.context}`);
|
|
1094
|
+
out.push("");
|
|
1095
|
+
sys.stderr.write(`${out.join("\n")}\n`);
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Rewrite args and exec the engine in place.
|
|
1100
|
+
*
|
|
1101
|
+
* Tag refs and cache scopes are normalized, then the wrapper is replaced by
|
|
1102
|
+
* the engine via execve so it inherits PID/exit code/signals.
|
|
1103
|
+
*/
|
|
1104
|
+
export function enginePassthrough(engine: string, argv: string[], tagPrefix: string, cachePrefix: string): number {
|
|
1105
|
+
// Validate/rewrite args first so input errors (bad tag, --cache with no
|
|
1106
|
+
// value, non-ASCII) surface as exit 2 even when the engine binary is not
|
|
1107
|
+
// installed.
|
|
1108
|
+
const [execArgs, summary] = parseBuildArgs(argv, tagPrefix, cachePrefix);
|
|
1109
|
+
|
|
1110
|
+
ensureHelperOnPath();
|
|
1111
|
+
if (!os.which(engine)) {
|
|
1112
|
+
print(`Engine not found: ${engine}`, sys.stderr);
|
|
1113
|
+
return 1;
|
|
1114
|
+
}
|
|
1115
|
+
if (!doWait([engine])) return 1;
|
|
1116
|
+
|
|
1117
|
+
formatBuildSummary(engine, execArgs, summary);
|
|
1118
|
+
|
|
1119
|
+
if (engine === "docker") os.execvp("docker", ["docker", ...execArgs]);
|
|
1120
|
+
// podman / buildah: prefer gosu if present so we drop privileges first.
|
|
1121
|
+
if (os.which("gosu")) {
|
|
1122
|
+
const user = process.env.USER ?? "";
|
|
1123
|
+
if (!user) {
|
|
1124
|
+
print(
|
|
1125
|
+
"ce: gosu is available but USER is not set; cannot drop privileges. Set USER or remove gosu from PATH.",
|
|
1126
|
+
sys.stderr,
|
|
1127
|
+
);
|
|
1128
|
+
return 1;
|
|
1129
|
+
}
|
|
1130
|
+
os.execvp("gosu", ["gosu", user, engine, ...execArgs]);
|
|
1131
|
+
}
|
|
1132
|
+
return os.execvp(engine, [engine, ...execArgs]);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
export async function main(argv: string[]): Promise<number> {
|
|
1136
|
+
if (argv.length === 0) {
|
|
1137
|
+
sys.stderr.write(helpText());
|
|
1138
|
+
return 0;
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
let tagPrefix = process.env.TAG_PREFIX ?? "";
|
|
1142
|
+
// Honor an explicitly empty CACHE_PREFIX rather than falling back to TAG_PREFIX.
|
|
1143
|
+
let cachePrefix = process.env.CACHE_PREFIX ?? process.env.TAG_PREFIX ?? "";
|
|
1144
|
+
|
|
1145
|
+
while (argv.length) {
|
|
1146
|
+
const a = argv[0]!;
|
|
1147
|
+
if (a === "--tag-prefix") {
|
|
1148
|
+
[tagPrefix, argv] = consumeValue(argv.slice(1), "--tag-prefix");
|
|
1149
|
+
} else if (a.startsWith("--tag-prefix=")) {
|
|
1150
|
+
tagPrefix = a.slice("--tag-prefix=".length);
|
|
1151
|
+
argv = argv.slice(1);
|
|
1152
|
+
} else if (a === "--cache-prefix") {
|
|
1153
|
+
[cachePrefix, argv] = consumeValue(argv.slice(1), "--cache-prefix");
|
|
1154
|
+
} else if (a.startsWith("--cache-prefix=")) {
|
|
1155
|
+
cachePrefix = a.slice("--cache-prefix=".length);
|
|
1156
|
+
argv = argv.slice(1);
|
|
1157
|
+
} else {
|
|
1158
|
+
break;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
if (argv.length === 0) {
|
|
1163
|
+
sys.stderr.write(helpText());
|
|
1164
|
+
return 0;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
const [command, ...rest] = argv as [string, ...string[]];
|
|
1168
|
+
if (command === "login") return cmdLogin();
|
|
1169
|
+
if (command === "credential-helper") return cmdCredentialHelper(rest);
|
|
1170
|
+
if (command === "docker" || command === "podman" || command === "buildah") {
|
|
1171
|
+
return enginePassthrough(command, rest, tagPrefix, cachePrefix);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
sys.stderr.write(helpText());
|
|
1175
|
+
return 1;
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
/** Entry point for the bin scripts: run `main` and exit with its status. */
|
|
1179
|
+
export async function entry(argv: string[]): Promise<never> {
|
|
1180
|
+
try {
|
|
1181
|
+
process.exit(await main(argv));
|
|
1182
|
+
} catch (exc) {
|
|
1183
|
+
// ValueErrors carry a clear message; surface it cleanly instead of as a
|
|
1184
|
+
// stack trace.
|
|
1185
|
+
if (!(exc instanceof ValueError)) throw exc;
|
|
1186
|
+
print(exc.message, sys.stderr);
|
|
1187
|
+
process.exit(2);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
package/src/libc.ts
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// The two libc calls Bun has no API for: flock(2) and execve(2).
|
|
2
|
+
//
|
|
3
|
+
// - flock: the Python `ce` serializes credential-helper state with
|
|
4
|
+
// `fcntl.flock(<state>.lock, LOCK_EX)`. Taking the very same kernel lock (not
|
|
5
|
+
// a lockfile scheme) keeps this port mutually exclusive with a Python `ce`
|
|
6
|
+
// sharing the same ~/.docker, and the kernel drops it when a holder dies.
|
|
7
|
+
// - execve: the Python `ce` ends with `os.execvp`, replacing itself with the
|
|
8
|
+
// engine so it keeps the PID, signals, TTY and exit status. A real execve does
|
|
9
|
+
// the same here, with no signal-forwarding or exit-code-relaying layer.
|
|
10
|
+
//
|
|
11
|
+
// libc is opened lazily so commands that never lock or exec (help, argument
|
|
12
|
+
// errors) do not depend on bun:ffi.
|
|
13
|
+
|
|
14
|
+
import { dlopen, FFIType, ptr, read } from "bun:ffi";
|
|
15
|
+
|
|
16
|
+
const LIBC_CANDIDATES: Record<string, string[]> = {
|
|
17
|
+
// musl resolves these reserved names to its built-in libc as well.
|
|
18
|
+
linux: ["libc.so.6", "libc.so", "libc.musl-x86_64.so.1", "libc.musl-aarch64.so.1"],
|
|
19
|
+
darwin: ["libc.dylib", "/usr/lib/libSystem.B.dylib"],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const symbols = {
|
|
23
|
+
flock: { args: [FFIType.i32, FFIType.i32], returns: FFIType.i32 },
|
|
24
|
+
execve: { args: [FFIType.ptr, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
|
|
25
|
+
strerror: { args: [FFIType.i32], returns: FFIType.cstring },
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
type Libc = ReturnType<typeof dlopen<typeof symbols>>["symbols"] & {
|
|
29
|
+
errnoLocation: () => number | null;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
let cached: Libc | undefined;
|
|
33
|
+
|
|
34
|
+
function libc(): Libc {
|
|
35
|
+
if (cached) return cached;
|
|
36
|
+
const errnoSymbol = process.platform === "darwin" ? "__error" : "__errno_location";
|
|
37
|
+
const errors: string[] = [];
|
|
38
|
+
for (const name of LIBC_CANDIDATES[process.platform] ?? ["libc.so.6"]) {
|
|
39
|
+
try {
|
|
40
|
+
const lib = dlopen(name, {
|
|
41
|
+
...symbols,
|
|
42
|
+
[errnoSymbol]: { args: [], returns: FFIType.ptr },
|
|
43
|
+
});
|
|
44
|
+
const s = lib.symbols as unknown as Record<string, (...a: unknown[]) => unknown>;
|
|
45
|
+
cached = {
|
|
46
|
+
...(lib.symbols as unknown as Libc),
|
|
47
|
+
errnoLocation: s[errnoSymbol] as () => number | null,
|
|
48
|
+
};
|
|
49
|
+
return cached;
|
|
50
|
+
} catch (exc) {
|
|
51
|
+
errors.push(`${name}: ${(exc as Error).message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`ce: cannot load libc via bun:ffi (${errors.join("; ")})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The calling thread's errno, read right after a failed libc call. */
|
|
58
|
+
export function errno(): number {
|
|
59
|
+
const location = libc().errnoLocation();
|
|
60
|
+
return location ? read.i32(location, 0) : 0;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** strerror(3) text, as Python puts in "[Errno N] <text>". */
|
|
64
|
+
export function strerror(code: number): string {
|
|
65
|
+
try {
|
|
66
|
+
return String(libc().strerror(code));
|
|
67
|
+
} catch {
|
|
68
|
+
return `Unknown error ${code}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const LOCK_EX = 2;
|
|
73
|
+
export const EINTR = 4;
|
|
74
|
+
|
|
75
|
+
/** Blocking flock(2). Returns 0 or the failing errno (EINTR is retried, as Python's PEP 475 does). */
|
|
76
|
+
export function flock(fd: number, operation: number): number {
|
|
77
|
+
for (;;) {
|
|
78
|
+
if (libc().flock(fd, operation) === 0) return 0;
|
|
79
|
+
const code = errno();
|
|
80
|
+
if (code !== EINTR) return code;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function cString(value: string): Buffer {
|
|
85
|
+
return Buffer.from(`${value}\0`, "utf8");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* execve(2). Only returns on failure, with the errno. The buffers stay
|
|
90
|
+
* referenced until the call returns, so the pointers handed to libc are valid.
|
|
91
|
+
*/
|
|
92
|
+
export function execve(path: string, argv: string[], env: Record<string, string | undefined>): number {
|
|
93
|
+
const pathBuf = cString(path);
|
|
94
|
+
const argBufs = argv.map(cString);
|
|
95
|
+
const envBufs = Object.entries(env)
|
|
96
|
+
.filter((entry): entry is [string, string] => entry[1] !== undefined)
|
|
97
|
+
.map(([key, value]) => cString(`${key}=${value}`));
|
|
98
|
+
const argPtrs = new BigUint64Array(argBufs.length + 1);
|
|
99
|
+
argBufs.forEach((buf, i) => (argPtrs[i] = BigInt(ptr(buf))));
|
|
100
|
+
const envPtrs = new BigUint64Array(envBufs.length + 1);
|
|
101
|
+
envBufs.forEach((buf, i) => (envPtrs[i] = BigInt(ptr(buf))));
|
|
102
|
+
libc().execve(pathBuf, argPtrs, envPtrs);
|
|
103
|
+
return errno();
|
|
104
|
+
}
|