@clearance/cli 0.1.4
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/LICENSE +29 -0
- package/LICENSE.md +20 -0
- package/dist/api-client.d.ts +18 -0
- package/dist/api-client.js +88 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2494 -0
- package/dist/operator-auth.d.ts +28 -0
- package/dist/operator-auth.js +310 -0
- package/dist/ops/deploy/compose/docker-compose.production.yml +129 -0
- package/dist/ops/deploy/upgrades/README.md +24 -0
- package/dist/ops/deploy/upgrades/steps/0.2.0/apply.sh +62 -0
- package/dist/ops/scripts/backup-create.sh +197 -0
- package/dist/ops/scripts/backup-restore-verify.sh +129 -0
- package/dist/ops/scripts/backup-verify.sh +121 -0
- package/dist/ops/scripts/lib/ops-common.sh +375 -0
- package/dist/ops/scripts/scim-legacy-preflight.sh +103 -0
- package/dist/ops/scripts/upgrade-apply.sh +168 -0
- package/dist/ops/scripts/upgrade-plan.sh +129 -0
- package/dist/ops/scripts/upgrade-preflight.sh +136 -0
- package/dist/ops/scripts/upgrade-rollback.sh +394 -0
- package/dist/ops/scripts/upgrade-verify.sh +98 -0
- package/dist/ops/scripts/validate-production-env.sh +232 -0
- package/dist/output.d.ts +19 -0
- package/dist/output.js +52 -0
- package/dist/remote-dispatch.d.ts +10 -0
- package/dist/remote-dispatch.js +232 -0
- package/dist/store.d.ts +9 -0
- package/dist/store.js +43 -0
- package/dist/upgrade.d.ts +114 -0
- package/dist/upgrade.js +262 -0
- package/package.json +38 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type OperatorCredential = {
|
|
2
|
+
version: 1;
|
|
3
|
+
apiUrl: string;
|
|
4
|
+
token: string;
|
|
5
|
+
};
|
|
6
|
+
export type OperatorWhoami = {
|
|
7
|
+
operator: {
|
|
8
|
+
id: string;
|
|
9
|
+
type: "operator";
|
|
10
|
+
authenticated: true;
|
|
11
|
+
};
|
|
12
|
+
projectId: string;
|
|
13
|
+
environmentId: string;
|
|
14
|
+
storeBackend: "json" | "postgres";
|
|
15
|
+
};
|
|
16
|
+
type AuthEnvironment = Partial<Pick<NodeJS.ProcessEnv, "CLEARANCE_CLI_CONFIG_DIR" | "XDG_CONFIG_HOME" | "HOME" | "CLEARANCE_API_URL" | "CLEARANCE_OPERATOR_TOKEN" | "CLEARANCE_API_TOKEN" | "CLEARANCE_PROFILE">>;
|
|
17
|
+
export declare function credentialDirectory(env?: AuthEnvironment): string;
|
|
18
|
+
export declare function normalizeProfile(profile: string | undefined, env?: AuthEnvironment): string;
|
|
19
|
+
export declare function credentialPath(env?: AuthEnvironment, profile?: string): string;
|
|
20
|
+
export declare function normalizeApiUrl(candidate: string | undefined, env?: AuthEnvironment): string;
|
|
21
|
+
export declare function environmentToken(env?: AuthEnvironment): string | undefined;
|
|
22
|
+
export declare function readSavedCredential(env?: AuthEnvironment, profile?: string): Promise<OperatorCredential | undefined>;
|
|
23
|
+
export declare function writeSavedCredential(credential: Omit<OperatorCredential, "version">, env?: AuthEnvironment, profile?: string): Promise<void>;
|
|
24
|
+
export declare function deleteSavedCredential(env?: AuthEnvironment, profile?: string): Promise<boolean>;
|
|
25
|
+
export declare function readTokenFromStdin(): Promise<string>;
|
|
26
|
+
export declare function fetchWhoami(apiUrl: string, token: string): Promise<OperatorWhoami>;
|
|
27
|
+
export declare function validateAndSaveCredential(apiUrl: string, token: string, env?: AuthEnvironment, profile?: string): Promise<OperatorWhoami>;
|
|
28
|
+
export {};
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { chmod, lstat, mkdir, open, rename, unlink, } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join, resolve } from "node:path";
|
|
5
|
+
import { constants } from "node:fs";
|
|
6
|
+
import { ClearanceError } from "@clearance/management";
|
|
7
|
+
const CREDENTIAL_FILENAME = "operator-credentials.json";
|
|
8
|
+
const CREDENTIAL_VERSION = 1;
|
|
9
|
+
const WHOAMI_TIMEOUT_MS = 5_000;
|
|
10
|
+
const MAX_STDIN_TOKEN_BYTES = 16 * 1024;
|
|
11
|
+
const MIN_TOKEN_BYTES = 16;
|
|
12
|
+
const MAX_CREDENTIAL_FILE_BYTES = 32 * 1024;
|
|
13
|
+
function error(code, message, remediation, retryable = false) {
|
|
14
|
+
return new ClearanceError({
|
|
15
|
+
code,
|
|
16
|
+
message,
|
|
17
|
+
stage: "operator-auth",
|
|
18
|
+
remediation,
|
|
19
|
+
retryable,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function credentialIoError(cause) {
|
|
23
|
+
if (cause instanceof ClearanceError)
|
|
24
|
+
return cause;
|
|
25
|
+
return error("CLI_CREDENTIAL_IO", "Saved credential storage could not be accessed safely.", "Check CLEARANCE_CLI_CONFIG_DIR permissions and try again.");
|
|
26
|
+
}
|
|
27
|
+
function fileMode(mode) {
|
|
28
|
+
return mode & 0o777;
|
|
29
|
+
}
|
|
30
|
+
async function requirePrivateDirectory(path, create) {
|
|
31
|
+
try {
|
|
32
|
+
const stat = await lstat(path);
|
|
33
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
34
|
+
throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory is not a regular private directory.", "Remove the unsafe credential directory and run clearance login again.");
|
|
35
|
+
}
|
|
36
|
+
if (fileMode(stat.mode) !== 0o700) {
|
|
37
|
+
throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory permissions must be 0700.", "Restrict the credential directory to its owner and run clearance login again.");
|
|
38
|
+
}
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
catch (cause) {
|
|
42
|
+
if (cause instanceof ClearanceError)
|
|
43
|
+
throw cause;
|
|
44
|
+
if (cause.code !== "ENOENT" || !create)
|
|
45
|
+
throw cause;
|
|
46
|
+
}
|
|
47
|
+
await mkdir(path, { recursive: true, mode: 0o700 });
|
|
48
|
+
const beforeChmod = await lstat(path);
|
|
49
|
+
if (beforeChmod.isSymbolicLink() || !beforeChmod.isDirectory()) {
|
|
50
|
+
throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory could not be secured.", "Set a private CLEARANCE_CLI_CONFIG_DIR and retry.");
|
|
51
|
+
}
|
|
52
|
+
await chmod(path, 0o700);
|
|
53
|
+
const stat = await lstat(path);
|
|
54
|
+
if (stat.isSymbolicLink() || !stat.isDirectory() || fileMode(stat.mode) !== 0o700) {
|
|
55
|
+
throw error("CLI_CREDENTIAL_DIRECTORY_UNSAFE", "Credential directory could not be secured.", "Set a private CLEARANCE_CLI_CONFIG_DIR and retry.");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
async function requirePrivateCredentialFile(path) {
|
|
59
|
+
const stat = await lstat(path);
|
|
60
|
+
if (stat.isSymbolicLink() || !stat.isFile() || fileMode(stat.mode) !== 0o600) {
|
|
61
|
+
throw error("CLI_CREDENTIAL_FILE_UNSAFE", "Saved credential file is not a regular file with mode 0600.", "Remove the unsafe credential file and log in again.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function credentialDirectory(env = process.env) {
|
|
65
|
+
const explicit = env.CLEARANCE_CLI_CONFIG_DIR?.trim();
|
|
66
|
+
if (explicit)
|
|
67
|
+
return resolve(explicit);
|
|
68
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
69
|
+
if (xdg)
|
|
70
|
+
return resolve(xdg, "clearance");
|
|
71
|
+
return join(env.HOME?.trim() || homedir(), ".config", "clearance");
|
|
72
|
+
}
|
|
73
|
+
export function normalizeProfile(profile, env = process.env) {
|
|
74
|
+
const value = profile?.trim() || env.CLEARANCE_PROFILE?.trim() || "default";
|
|
75
|
+
if (!/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/.test(value)) {
|
|
76
|
+
throw error("CLI_PROFILE_INVALID", "Clearance profile names must be lowercase letters, numbers, or hyphens.", "Choose a profile slug such as default, staging, or production-us.");
|
|
77
|
+
}
|
|
78
|
+
return value;
|
|
79
|
+
}
|
|
80
|
+
export function credentialPath(env = process.env, profile) {
|
|
81
|
+
const selected = normalizeProfile(profile, env);
|
|
82
|
+
const filename = selected === "default" ? CREDENTIAL_FILENAME : `operator-credentials.${selected}.json`;
|
|
83
|
+
return join(credentialDirectory(env), filename);
|
|
84
|
+
}
|
|
85
|
+
function isLoopbackHost(hostname) {
|
|
86
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
87
|
+
return host === "localhost" || host === "::1" || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
88
|
+
}
|
|
89
|
+
export function normalizeApiUrl(candidate, env = process.env) {
|
|
90
|
+
const value = candidate?.trim() || env.CLEARANCE_API_URL?.trim() || "http://localhost:3200";
|
|
91
|
+
let url;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(value);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
throw error("CLI_API_URL_INVALID", "Clearance API URL is invalid.", "Pass a valid --url or set CLEARANCE_API_URL.");
|
|
97
|
+
}
|
|
98
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
99
|
+
throw error("CLI_API_URL_INVALID", "Clearance API URL must use HTTPS or local HTTP.", "Use an HTTPS URL or a localhost loopback HTTP URL.");
|
|
100
|
+
}
|
|
101
|
+
if (url.protocol === "http:" && !isLoopbackHost(url.hostname)) {
|
|
102
|
+
throw error("CLI_API_URL_INSECURE", "Remote Clearance API URLs must use HTTPS.", "Use HTTPS for remote APIs; HTTP is allowed only for localhost loopback.");
|
|
103
|
+
}
|
|
104
|
+
if (url.username || url.password || url.search || url.hash || (url.pathname !== "/" && url.pathname !== "")) {
|
|
105
|
+
throw error("CLI_API_URL_INVALID", "Clearance API URL must be an origin without credentials, path, query, or fragment.", "Pass the API origin, for example https://api.example.com.");
|
|
106
|
+
}
|
|
107
|
+
return url.origin;
|
|
108
|
+
}
|
|
109
|
+
export function environmentToken(env = process.env) {
|
|
110
|
+
return env.CLEARANCE_OPERATOR_TOKEN?.trim() || env.CLEARANCE_API_TOKEN?.trim() || undefined;
|
|
111
|
+
}
|
|
112
|
+
function validateOperatorToken(token) {
|
|
113
|
+
const size = Buffer.byteLength(token, "utf8");
|
|
114
|
+
if (size < MIN_TOKEN_BYTES || size > MAX_STDIN_TOKEN_BYTES || /\s/.test(token)) {
|
|
115
|
+
throw error("CLI_TOKEN_INVALID", "Operator token is invalid.", "Provide a bearer token between 16 bytes and 16 KiB without whitespace.");
|
|
116
|
+
}
|
|
117
|
+
return token;
|
|
118
|
+
}
|
|
119
|
+
function parseCredential(raw) {
|
|
120
|
+
let value;
|
|
121
|
+
try {
|
|
122
|
+
value = JSON.parse(raw);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
throw error("CLI_CREDENTIAL_INVALID", "Saved credential file is not valid JSON.", "Log in again to create a new credential file.");
|
|
126
|
+
}
|
|
127
|
+
if (!value ||
|
|
128
|
+
typeof value !== "object" ||
|
|
129
|
+
Array.isArray(value) ||
|
|
130
|
+
Object.keys(value).length !== 3 ||
|
|
131
|
+
!["version", "apiUrl", "token"].every((key) => Object.prototype.hasOwnProperty.call(value, key))) {
|
|
132
|
+
throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
|
|
133
|
+
}
|
|
134
|
+
const credential = value;
|
|
135
|
+
if (credential.version !== CREDENTIAL_VERSION ||
|
|
136
|
+
typeof credential.apiUrl !== "string" ||
|
|
137
|
+
typeof credential.token !== "string") {
|
|
138
|
+
throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
|
|
139
|
+
}
|
|
140
|
+
try {
|
|
141
|
+
validateOperatorToken(credential.token);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
throw error("CLI_CREDENTIAL_INVALID", "Saved credential file has an invalid schema.", "Log in again to create a new credential file.");
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
version: CREDENTIAL_VERSION,
|
|
148
|
+
apiUrl: normalizeApiUrl(credential.apiUrl, {}),
|
|
149
|
+
token: credential.token,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
export async function readSavedCredential(env = process.env, profile) {
|
|
153
|
+
const directory = credentialDirectory(env);
|
|
154
|
+
const path = credentialPath(env, profile);
|
|
155
|
+
try {
|
|
156
|
+
await requirePrivateDirectory(directory, false);
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
if (cause.code === "ENOENT")
|
|
160
|
+
return undefined;
|
|
161
|
+
throw credentialIoError(cause);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
165
|
+
try {
|
|
166
|
+
const stat = await handle.stat();
|
|
167
|
+
if (!stat.isFile() || fileMode(stat.mode) !== 0o600 || stat.size > MAX_CREDENTIAL_FILE_BYTES) {
|
|
168
|
+
throw error("CLI_CREDENTIAL_FILE_UNSAFE", "Saved credential file is not a regular, bounded file with mode 0600.", "Remove the unsafe credential file and log in again.");
|
|
169
|
+
}
|
|
170
|
+
return parseCredential(await handle.readFile("utf8"));
|
|
171
|
+
}
|
|
172
|
+
finally {
|
|
173
|
+
await handle.close();
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
catch (cause) {
|
|
177
|
+
if (cause.code === "ENOENT")
|
|
178
|
+
return undefined;
|
|
179
|
+
throw credentialIoError(cause);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
export async function writeSavedCredential(credential, env = process.env, profile) {
|
|
183
|
+
const directory = credentialDirectory(env);
|
|
184
|
+
const selectedProfile = normalizeProfile(profile, env);
|
|
185
|
+
const path = credentialPath(env, selectedProfile);
|
|
186
|
+
try {
|
|
187
|
+
await requirePrivateDirectory(directory, true);
|
|
188
|
+
}
|
|
189
|
+
catch (cause) {
|
|
190
|
+
throw credentialIoError(cause);
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
await requirePrivateCredentialFile(path);
|
|
194
|
+
}
|
|
195
|
+
catch (cause) {
|
|
196
|
+
if (cause.code !== "ENOENT")
|
|
197
|
+
throw credentialIoError(cause);
|
|
198
|
+
}
|
|
199
|
+
const normalized = {
|
|
200
|
+
version: CREDENTIAL_VERSION,
|
|
201
|
+
apiUrl: normalizeApiUrl(credential.apiUrl, {}),
|
|
202
|
+
token: validateOperatorToken(credential.token),
|
|
203
|
+
};
|
|
204
|
+
const temporary = join(directory, `.${selectedProfile}.${CREDENTIAL_FILENAME}.${randomBytes(16).toString("hex")}.tmp`);
|
|
205
|
+
let handle;
|
|
206
|
+
try {
|
|
207
|
+
handle = await open(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
|
208
|
+
await handle.writeFile(`${JSON.stringify(normalized)}\n`, "utf8");
|
|
209
|
+
await handle.sync();
|
|
210
|
+
await handle.close();
|
|
211
|
+
handle = undefined;
|
|
212
|
+
await rename(temporary, path);
|
|
213
|
+
await requirePrivateCredentialFile(path);
|
|
214
|
+
}
|
|
215
|
+
catch (cause) {
|
|
216
|
+
await handle?.close().catch(() => undefined);
|
|
217
|
+
await unlink(temporary).catch(() => undefined);
|
|
218
|
+
throw credentialIoError(cause);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
export async function deleteSavedCredential(env = process.env, profile) {
|
|
222
|
+
const directory = credentialDirectory(env);
|
|
223
|
+
const path = credentialPath(env, profile);
|
|
224
|
+
try {
|
|
225
|
+
await requirePrivateDirectory(directory, false);
|
|
226
|
+
}
|
|
227
|
+
catch (cause) {
|
|
228
|
+
if (cause.code === "ENOENT")
|
|
229
|
+
return false;
|
|
230
|
+
throw credentialIoError(cause);
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
await requirePrivateCredentialFile(path);
|
|
234
|
+
await unlink(path);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
catch (cause) {
|
|
238
|
+
if (cause.code === "ENOENT")
|
|
239
|
+
return false;
|
|
240
|
+
throw credentialIoError(cause);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
export async function readTokenFromStdin() {
|
|
244
|
+
let input = "";
|
|
245
|
+
for await (const chunk of process.stdin) {
|
|
246
|
+
input += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
247
|
+
if (Buffer.byteLength(input, "utf8") > MAX_STDIN_TOKEN_BYTES) {
|
|
248
|
+
throw error("CLI_TOKEN_INVALID", "Operator token input is too large.", "Provide one bearer token on standard input.");
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return validateOperatorToken(input.trim());
|
|
252
|
+
}
|
|
253
|
+
function parseWhoami(value) {
|
|
254
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
255
|
+
throw error("CLI_WHOAMI_INVALID_RESPONSE", "Clearance API returned an invalid whoami response.", "Upgrade the Clearance API or check the configured API URL.");
|
|
256
|
+
}
|
|
257
|
+
const response = value;
|
|
258
|
+
const operator = response.operator;
|
|
259
|
+
if (!operator ||
|
|
260
|
+
operator.id !== "operator" ||
|
|
261
|
+
operator.type !== "operator" ||
|
|
262
|
+
operator.authenticated !== true ||
|
|
263
|
+
typeof response.projectId !== "string" ||
|
|
264
|
+
!response.projectId ||
|
|
265
|
+
typeof response.environmentId !== "string" ||
|
|
266
|
+
!response.environmentId ||
|
|
267
|
+
(response.storeBackend !== "json" && response.storeBackend !== "postgres")) {
|
|
268
|
+
throw error("CLI_WHOAMI_INVALID_RESPONSE", "Clearance API returned an invalid whoami response.", "Upgrade the Clearance API or check the configured API URL.");
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
operator: { id: "operator", type: "operator", authenticated: true },
|
|
272
|
+
projectId: response.projectId,
|
|
273
|
+
environmentId: response.environmentId,
|
|
274
|
+
storeBackend: response.storeBackend,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
export async function fetchWhoami(apiUrl, token) {
|
|
278
|
+
const validatedToken = validateOperatorToken(token);
|
|
279
|
+
const controller = new AbortController();
|
|
280
|
+
const timer = setTimeout(() => controller.abort(), WHOAMI_TIMEOUT_MS);
|
|
281
|
+
try {
|
|
282
|
+
const response = await fetch(`${normalizeApiUrl(apiUrl, {})}/v1/whoami`, {
|
|
283
|
+
headers: { authorization: `Bearer ${validatedToken}`, accept: "application/json" },
|
|
284
|
+
signal: controller.signal,
|
|
285
|
+
});
|
|
286
|
+
if (response.status === 401) {
|
|
287
|
+
throw error("CLI_AUTH_UNAUTHORIZED", "Clearance API rejected the operator credential.", "Provide a valid operator token and try again.");
|
|
288
|
+
}
|
|
289
|
+
if (!response.ok) {
|
|
290
|
+
throw error("CLI_WHOAMI_FAILED", "Clearance API could not verify the operator credential.", "Check the API URL and operator access, then try again.", response.status >= 500);
|
|
291
|
+
}
|
|
292
|
+
return parseWhoami(await response.json());
|
|
293
|
+
}
|
|
294
|
+
catch (cause) {
|
|
295
|
+
if (cause instanceof ClearanceError)
|
|
296
|
+
throw cause;
|
|
297
|
+
if (cause.name === "AbortError") {
|
|
298
|
+
throw error("CLI_API_TIMEOUT", "Clearance API verification timed out.", "Check API reachability and try again.", true);
|
|
299
|
+
}
|
|
300
|
+
throw error("CLI_API_UNREACHABLE", "Clearance API could not be reached.", "Check the API URL and network connection, then try again.", true);
|
|
301
|
+
}
|
|
302
|
+
finally {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
export async function validateAndSaveCredential(apiUrl, token, env = process.env, profile) {
|
|
307
|
+
const whoami = await fetchWhoami(apiUrl, token);
|
|
308
|
+
await writeSavedCredential({ apiUrl, token }, env, profile);
|
|
309
|
+
return whoami;
|
|
310
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Production overlay for Clearance.
|
|
2
|
+
#
|
|
3
|
+
# Usage (after validating secrets):
|
|
4
|
+
# scripts/validate-production-env.sh
|
|
5
|
+
# docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml config
|
|
6
|
+
# docker compose -f docker-compose.yml -f deploy/compose/docker-compose.production.yml up -d
|
|
7
|
+
#
|
|
8
|
+
# Fail-closed rules:
|
|
9
|
+
# - NODE_ENV is forced to production
|
|
10
|
+
# - DATABASE_URL must be a complete, operator-supplied connection string
|
|
11
|
+
# (no shell construction of password-bearing URLs — avoids @:/#? interpolation breakage)
|
|
12
|
+
# - Every secret is required with no defaults
|
|
13
|
+
# - Postgres is NOT published to the host by default (ports reset)
|
|
14
|
+
# - Credential encryption key + key id are mandatory
|
|
15
|
+
# - No VAR-with-default secret fallbacks (only fail-closed ${VAR:?...} forms)
|
|
16
|
+
services:
|
|
17
|
+
postgres:
|
|
18
|
+
environment:
|
|
19
|
+
POSTGRES_USER: ${CLEARANCE_DB_USER:?set CLEARANCE_DB_USER}
|
|
20
|
+
POSTGRES_PASSWORD: ${CLEARANCE_DB_PASSWORD:?set CLEARANCE_DB_PASSWORD}
|
|
21
|
+
POSTGRES_DB: ${CLEARANCE_DB_NAME:?set CLEARANCE_DB_NAME}
|
|
22
|
+
# Production default: internal network only. Host publish requires a local override file.
|
|
23
|
+
ports: !reset []
|
|
24
|
+
volumes:
|
|
25
|
+
- clearance_pg:/var/lib/postgresql/data
|
|
26
|
+
healthcheck:
|
|
27
|
+
# $$ defers expansion to the container shell (avoids host-side compose interpolation)
|
|
28
|
+
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
|
|
29
|
+
interval: 5s
|
|
30
|
+
timeout: 5s
|
|
31
|
+
retries: 12
|
|
32
|
+
restart: always
|
|
33
|
+
stop_grace_period: 30s
|
|
34
|
+
|
|
35
|
+
api:
|
|
36
|
+
build: !reset null
|
|
37
|
+
image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
|
|
38
|
+
environment:
|
|
39
|
+
NODE_ENV: production
|
|
40
|
+
CLEARANCE_STRICT_SECRETS: "1"
|
|
41
|
+
CLEARANCE_API_PORT: "3200"
|
|
42
|
+
CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
|
|
43
|
+
CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
|
|
44
|
+
CLEARANCE_CREDENTIAL_KEY: ${CLEARANCE_CREDENTIAL_KEY:?set CLEARANCE_CREDENTIAL_KEY}
|
|
45
|
+
CLEARANCE_CREDENTIAL_KEY_ID: ${CLEARANCE_CREDENTIAL_KEY_ID:?set CLEARANCE_CREDENTIAL_KEY_ID}
|
|
46
|
+
CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
|
|
47
|
+
CLEARANCE_CONSOLE_URL: ${CLEARANCE_CONSOLE_URL:?set CLEARANCE_CONSOLE_URL}
|
|
48
|
+
CLEARANCE_CORS_ORIGINS: ${CLEARANCE_CORS_ORIGINS:?set CLEARANCE_CORS_ORIGINS}
|
|
49
|
+
# Full connection string required — do not embed raw passwords via compose interpolation.
|
|
50
|
+
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
|
|
51
|
+
# Fail-closed override of the dev base file: this profile publishes the
|
|
52
|
+
# API port, so x-forwarded-for from arbitrary clients must NOT be
|
|
53
|
+
# trusted for rate-limit keying. Hardcoded (this overlay forbids
|
|
54
|
+
# ${VAR:-default} interpolation); edit this line to "1" only if your
|
|
55
|
+
# deployment makes the API reachable EXCLUSIVELY via trusted proxies
|
|
56
|
+
# (e.g. the console BFF or a fronting load balancer).
|
|
57
|
+
CLEARANCE_TRUSTED_PROXY: "0"
|
|
58
|
+
# Replace the base loopback mapping instead of merging a second binding.
|
|
59
|
+
ports: !override
|
|
60
|
+
- "127.0.0.1:${CLEARANCE_API_PORT:?set CLEARANCE_API_PORT}:3200"
|
|
61
|
+
restart: always
|
|
62
|
+
stop_grace_period: 30s
|
|
63
|
+
|
|
64
|
+
console:
|
|
65
|
+
build: !reset null
|
|
66
|
+
image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
|
|
67
|
+
environment:
|
|
68
|
+
NODE_ENV: production
|
|
69
|
+
CLEARANCE_STRICT_SECRETS: "1"
|
|
70
|
+
CLEARANCE_CONSOLE_PORT: "3100"
|
|
71
|
+
CLEARANCE_API_URL: http://api:3200
|
|
72
|
+
CLEARANCE_OPERATOR_TOKEN: ${CLEARANCE_OPERATOR_TOKEN:?set CLEARANCE_OPERATOR_TOKEN}
|
|
73
|
+
CLEARANCE_CONSOLE_ADMIN_USER: ${CLEARANCE_CONSOLE_ADMIN_USER:?set CLEARANCE_CONSOLE_ADMIN_USER}
|
|
74
|
+
CLEARANCE_CONSOLE_ADMIN_PASSWORD: ${CLEARANCE_CONSOLE_ADMIN_PASSWORD:?set CLEARANCE_CONSOLE_ADMIN_PASSWORD}
|
|
75
|
+
CLEARANCE_CONSOLE_SESSION_SECRET: ${CLEARANCE_CONSOLE_SESSION_SECRET:?set CLEARANCE_CONSOLE_SESSION_SECRET}
|
|
76
|
+
# Replace the base loopback mapping instead of merging a second binding.
|
|
77
|
+
ports: !override
|
|
78
|
+
- "127.0.0.1:${CLEARANCE_CONSOLE_PORT:?set CLEARANCE_CONSOLE_PORT}:3100"
|
|
79
|
+
restart: always
|
|
80
|
+
stop_grace_period: 30s
|
|
81
|
+
|
|
82
|
+
sample-b2b:
|
|
83
|
+
build: !reset null
|
|
84
|
+
image: "${CLEARANCE_IMAGE_REPOSITORY:?set CLEARANCE_IMAGE_REPOSITORY}@${CLEARANCE_IMAGE_DIGEST:?set signed CLEARANCE_IMAGE_DIGEST}"
|
|
85
|
+
environment:
|
|
86
|
+
NODE_ENV: production
|
|
87
|
+
CLEARANCE_STRICT_SECRETS: "1"
|
|
88
|
+
SAMPLE_APP_PORT: "3000"
|
|
89
|
+
CLEARANCE_SECRET: ${CLEARANCE_SECRET:?set CLEARANCE_SECRET}
|
|
90
|
+
CLEARANCE_BASE_URL: ${CLEARANCE_BASE_URL:?set CLEARANCE_BASE_URL}
|
|
91
|
+
# Optional social providers. The application fails startup on incomplete pairs.
|
|
92
|
+
CLEARANCE_GITHUB_CLIENT_ID: ${CLEARANCE_GITHUB_CLIENT_ID-}
|
|
93
|
+
CLEARANCE_GITHUB_CLIENT_SECRET: ${CLEARANCE_GITHUB_CLIENT_SECRET-}
|
|
94
|
+
CLEARANCE_GOOGLE_CLIENT_ID: ${CLEARANCE_GOOGLE_CLIENT_ID-}
|
|
95
|
+
CLEARANCE_GOOGLE_CLIENT_SECRET: ${CLEARANCE_GOOGLE_CLIENT_SECRET-}
|
|
96
|
+
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
|
|
97
|
+
# Replace the base loopback mapping instead of merging a second binding.
|
|
98
|
+
ports: !override
|
|
99
|
+
- "127.0.0.1:${CLEARANCE_SAMPLE_PORT:?set CLEARANCE_SAMPLE_PORT}:3000"
|
|
100
|
+
restart: always
|
|
101
|
+
|
|
102
|
+
# One-shot scheduled job. Invoke from cron/systemd:
|
|
103
|
+
# docker compose ... --profile backup run --rm backup
|
|
104
|
+
# The script fails closed unless CLEARANCE_BACKUP_COPY_COMMAND succeeds.
|
|
105
|
+
backup:
|
|
106
|
+
profiles: ["backup"]
|
|
107
|
+
build: !reset null
|
|
108
|
+
image: "${CLEARANCE_BACKUP_IMAGE_REPOSITORY:?set CLEARANCE_BACKUP_IMAGE_REPOSITORY}@${CLEARANCE_BACKUP_IMAGE_DIGEST:?set signed CLEARANCE_BACKUP_IMAGE_DIGEST}"
|
|
109
|
+
command: ["bash", "scripts/backup-scheduled.sh", "--dir", "/backups"]
|
|
110
|
+
environment:
|
|
111
|
+
DATABASE_URL: ${DATABASE_URL:?set DATABASE_URL to a full postgres URL with percent-encoded password}
|
|
112
|
+
CLEARANCE_BACKUP_COPY_COMMAND: ${CLEARANCE_BACKUP_COPY_COMMAND-}
|
|
113
|
+
CLEARANCE_BACKUP_RETENTION_DAYS: ${CLEARANCE_BACKUP_RETENTION_DAYS-30}
|
|
114
|
+
CLEARANCE_BACKUP_RESTORE_VERIFY: "1"
|
|
115
|
+
volumes:
|
|
116
|
+
- clearance_backups:/backups
|
|
117
|
+
tmpfs:
|
|
118
|
+
- /tmp
|
|
119
|
+
read_only: true
|
|
120
|
+
depends_on:
|
|
121
|
+
postgres:
|
|
122
|
+
condition: service_healthy
|
|
123
|
+
restart: "no"
|
|
124
|
+
|
|
125
|
+
volumes:
|
|
126
|
+
clearance_pg:
|
|
127
|
+
name: ${CLEARANCE_PG_VOLUME:?set CLEARANCE_PG_VOLUME to an explicit volume name}
|
|
128
|
+
clearance_backups:
|
|
129
|
+
name: ${CLEARANCE_BACKUP_VOLUME:?set CLEARANCE_BACKUP_VOLUME to an explicit volume name}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Upgrade step hooks
|
|
2
|
+
|
|
3
|
+
`scripts/upgrade-apply.sh` requires a hook:
|
|
4
|
+
|
|
5
|
+
```text
|
|
6
|
+
deploy/upgrades/steps/<targetVersion>/apply.sh
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The currently supported transition is `0.1.4` to `0.2.0`. Its shipped hook is
|
|
10
|
+
`deploy/upgrades/steps/0.2.0/apply.sh`; it verifies the release marker is at
|
|
11
|
+
`0.1.4`, advances it to `0.2.0`, and verifies the result. The Clearance CLI
|
|
12
|
+
packages this hook under `dist/ops/deploy/upgrades/steps/0.2.0/apply.sh`.
|
|
13
|
+
|
|
14
|
+
## Contract
|
|
15
|
+
|
|
16
|
+
- Invoked only after preflight and a **verified** Postgres backup (with isolated restore verification).
|
|
17
|
+
- Args: `--plan PATH --from CURRENT --to TARGET`
|
|
18
|
+
- Exit non-zero to fail closed (no silent partial apply).
|
|
19
|
+
- Must not drop or overwrite the active database; destructive changes require explicit operator runbooks.
|
|
20
|
+
- Rollback reference is always the verified backup id recorded in the plan state sidecar.
|
|
21
|
+
|
|
22
|
+
Without a hook, apply fails after creating and verifying its rollback backup. It never records a no-op version transition as applied.
|
|
23
|
+
|
|
24
|
+
`scripts/upgrade-rollback.sh` is a rollback **drill**: it verifies the deterministic backup reference through an isolated restore and leaves the active environment unchanged. An actual rollback remains an operator-runbook action until active restore automation is designed and tested separately.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shipped 0.1.4 -> 0.2.0 database transition.
|
|
3
|
+
set -Eeuo pipefail
|
|
4
|
+
|
|
5
|
+
ROOT="$(cd "$(dirname "$0")/../../../.." && pwd)"
|
|
6
|
+
# shellcheck source=scripts/lib/ops-common.sh
|
|
7
|
+
source "$ROOT/scripts/lib/ops-common.sh"
|
|
8
|
+
|
|
9
|
+
PLAN=""
|
|
10
|
+
FROM=""
|
|
11
|
+
TO=""
|
|
12
|
+
while [[ $# -gt 0 ]]; do
|
|
13
|
+
case "$1" in
|
|
14
|
+
--plan) PLAN="$2"; shift 2 ;;
|
|
15
|
+
--from) FROM="$2"; shift 2 ;;
|
|
16
|
+
--to) TO="$2"; shift 2 ;;
|
|
17
|
+
*) die "unknown upgrade hook argument: $1" ;;
|
|
18
|
+
esac
|
|
19
|
+
done
|
|
20
|
+
|
|
21
|
+
[[ -f "$PLAN" ]] || die "upgrade plan is missing"
|
|
22
|
+
[[ "$FROM" == "0.1.4" && "$TO" == "0.2.0" ]] \
|
|
23
|
+
|| die "this hook only supports 0.1.4 to 0.2.0"
|
|
24
|
+
|
|
25
|
+
require_pg_client
|
|
26
|
+
require_cmd node
|
|
27
|
+
URL="$(resolve_database_url)"
|
|
28
|
+
PLAN_ID="$(node -e 'const j=require(process.argv[1]); process.stdout.write(j.planId)' "$PLAN")"
|
|
29
|
+
PLAN_SHA="$(sha256_file "$PLAN")"
|
|
30
|
+
[[ "$PLAN_ID" =~ ^upg_[0-9TZ]+_[a-f0-9]+$ ]] || die "unsafe plan id"
|
|
31
|
+
require_application_release "$URL" "$FROM"
|
|
32
|
+
|
|
33
|
+
# This is the application-owned migration contract: the durable management
|
|
34
|
+
# snapshot used by the running API advances atomically with an append-only
|
|
35
|
+
# migration ledger. It cannot pass against the old test-only ops marker.
|
|
36
|
+
psql_q "$URL" \
|
|
37
|
+
-v from_version="$FROM" -v to_version="$TO" \
|
|
38
|
+
-v plan_id="$PLAN_ID" -v plan_sha="$PLAN_SHA" <<'SQL' >/dev/null
|
|
39
|
+
BEGIN;
|
|
40
|
+
CREATE TABLE IF NOT EXISTS clearance_schema_migrations (
|
|
41
|
+
version text PRIMARY KEY,
|
|
42
|
+
applied_at timestamptz NOT NULL DEFAULT now(),
|
|
43
|
+
plan_id text NOT NULL,
|
|
44
|
+
plan_sha256 text NOT NULL,
|
|
45
|
+
from_version text
|
|
46
|
+
);
|
|
47
|
+
INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
|
|
48
|
+
VALUES (:'from_version', 'application-baseline', :'plan_sha', NULL)
|
|
49
|
+
ON CONFLICT (version) DO NOTHING;
|
|
50
|
+
UPDATE clearance_management_snapshot
|
|
51
|
+
SET data = jsonb_set(data, '{releaseVersion}', to_jsonb(:'to_version'::text), false),
|
|
52
|
+
revision = revision + 1,
|
|
53
|
+
updated_at = now()
|
|
54
|
+
WHERE id = 1 AND data->>'releaseVersion' = :'from_version';
|
|
55
|
+
INSERT INTO clearance_schema_migrations(version, plan_id, plan_sha256, from_version)
|
|
56
|
+
VALUES (:'to_version', :'plan_id', :'plan_sha', :'from_version');
|
|
57
|
+
COMMIT;
|
|
58
|
+
SQL
|
|
59
|
+
|
|
60
|
+
require_application_release "$URL" "$TO"
|
|
61
|
+
ledger_plan="$(psql_q "$URL" -At -c "SELECT plan_id FROM clearance_schema_migrations WHERE version = '${TO}'")"
|
|
62
|
+
[[ "$ledger_plan" == "$PLAN_ID" ]] || die "migration ledger did not record plan $PLAN_ID"
|