@magoz/provision 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +498 -2
- package/dist/cli.js +29 -0
- package/dist/contract.js +13 -0
- package/dist/db/branch-lifecycle.js +23 -0
- package/dist/db/commands.js +303 -0
- package/dist/db/config.js +75 -0
- package/dist/db/credentials.js +177 -0
- package/dist/db/domain.js +106 -0
- package/dist/db/environment.js +14 -0
- package/dist/db/lease.js +132 -0
- package/dist/db/neon.js +350 -0
- package/dist/db/operations.js +246 -0
- package/dist/db/policy.js +59 -0
- package/dist/env/commands.js +39 -0
- package/dist/env/domain.js +14 -0
- package/dist/env/install.js +25 -0
- package/dist/env/paths.js +105 -0
- package/dist/env/provision.js +320 -0
- package/dist/env/sanitize.js +88 -0
- package/dist/env/vercel.js +180 -0
- package/dist/factory/approval.js +33 -0
- package/dist/factory/commands.js +173 -0
- package/dist/factory/config-commands.js +215 -0
- package/dist/factory/context.js +129 -0
- package/dist/factory/http.js +37 -0
- package/dist/factory/onboarding.js +155 -0
- package/dist/factory/onepassword.js +140 -0
- package/dist/factory/op-credential.js +154 -0
- package/dist/factory/passphrase.js +145 -0
- package/dist/factory/providers/cloudflare.js +126 -0
- package/dist/factory/providers/neon.js +81 -0
- package/dist/factory/providers/report-receiver.js +37 -0
- package/dist/factory/providers/resend.js +32 -0
- package/dist/factory/providers/upstash.js +21 -0
- package/dist/factory/registry.js +50 -0
- package/dist/factory/scoped-key.js +30 -0
- package/dist/factory/sealed.js +64 -0
- package/dist/factory/secret-input.js +43 -0
- package/dist/factory/steps/domain.js +23 -0
- package/dist/factory/steps/neon.js +101 -0
- package/dist/factory/steps/r2.js +70 -0
- package/dist/factory/steps/reports.js +44 -0
- package/dist/factory/steps/resend.js +37 -0
- package/dist/factory/steps/secrets.js +104 -0
- package/dist/factory/steps/upstash.js +92 -0
- package/dist/factory/steps/vercel.js +68 -0
- package/dist/factory/vercel-api.js +165 -0
- package/dist/package-info.js +15 -0
- package/dist/shared/agent.js +28 -0
- package/dist/shared/env-file.js +54 -0
- package/dist/shared/git.js +48 -0
- package/dist/shared/output.js +10 -0
- package/dist/shared/private-file.js +63 -0
- package/dist/shared/process.js +55 -0
- package/dist/shared/repo-config.js +104 -0
- package/dist/shared/sandbox-profile.js +12 -0
- package/package.json +45 -4
- package/skills/provision/SKILL.md +172 -0
package/dist/db/lease.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from 'effect';
|
|
3
|
+
import { LeaseError, LeaseFromJson, LeaseRecordFromJson, LifecycleError, normalizeLease } from './domain.js';
|
|
4
|
+
import { DbEnvironment } from './environment.js';
|
|
5
|
+
const leaseDirectory = Effect.gen(function* () {
|
|
6
|
+
const path = yield* Path.Path;
|
|
7
|
+
const fs = yield* FileSystem.FileSystem;
|
|
8
|
+
const directory = path.join((yield* DbEnvironment).stateDirectory, 'leases');
|
|
9
|
+
yield* fs
|
|
10
|
+
.makeDirectory(directory, { recursive: true })
|
|
11
|
+
.pipe(Effect.mapError(cause => new LeaseError({ message: `lease directory unavailable: ${cause.message}` })));
|
|
12
|
+
return directory;
|
|
13
|
+
});
|
|
14
|
+
/** The `default` slot keeps the original single-lease identity so old records stay readable. */
|
|
15
|
+
const leaseIdentity = (worktree, leaseName) => leaseName === 'default' ? worktree : JSON.stringify([worktree, leaseName]);
|
|
16
|
+
const leaseDigest = (worktree, leaseName) => createHash('sha256').update(leaseIdentity(worktree, leaseName)).digest('hex').slice(0, 16);
|
|
17
|
+
const leaseFile = (worktree, leaseName) => Effect.gen(function* () {
|
|
18
|
+
const path = yield* Path.Path;
|
|
19
|
+
return path.join(yield* leaseDirectory, `${leaseDigest(worktree, leaseName)}.json`);
|
|
20
|
+
});
|
|
21
|
+
export const acquireLeaseLock = (worktree, leaseName) => Effect.gen(function* () {
|
|
22
|
+
const fs = yield* FileSystem.FileSystem;
|
|
23
|
+
const path = yield* Path.Path;
|
|
24
|
+
const locks = path.join((yield* DbEnvironment).stateDirectory, 'locks');
|
|
25
|
+
yield* fs
|
|
26
|
+
.makeDirectory(locks, { recursive: true })
|
|
27
|
+
.pipe(Effect.mapError(() => new LeaseError({ message: `lease lock directory unavailable: ${locks}` })));
|
|
28
|
+
const lock = path.join(locks, `${leaseDigest(worktree, leaseName)}.lock`);
|
|
29
|
+
yield* fs.makeDirectory(lock).pipe(Effect.mapError(() => new LeaseError({
|
|
30
|
+
message: `another provision db operation owns lease slot ${leaseName}; ` +
|
|
31
|
+
`if none is running, remove stale lock ${lock}; branch TTL remains the cleanup backstop`
|
|
32
|
+
})));
|
|
33
|
+
return lock;
|
|
34
|
+
});
|
|
35
|
+
export const releaseLeaseLock = (lock) => Effect.gen(function* () {
|
|
36
|
+
const fs = yield* FileSystem.FileSystem;
|
|
37
|
+
yield* fs
|
|
38
|
+
.remove(lock, { recursive: true })
|
|
39
|
+
.pipe(Effect.mapError(() => new LeaseError({ message: `cannot release lease lock ${lock}` })));
|
|
40
|
+
});
|
|
41
|
+
const replayExit = (exit) => Exit.isSuccess(exit) ? Effect.succeed(exit.value) : Effect.failCause(exit.cause);
|
|
42
|
+
const isUnexpected = (cause) => Cause.hasInterrupts(cause) || Cause.hasDies(cause);
|
|
43
|
+
/**
|
|
44
|
+
* Runs `operation` while holding a lease lock. A lock-release failure surfaces on its own, never
|
|
45
|
+
* replaces a lone operation failure, and never turns a defect or interruption into a typed error.
|
|
46
|
+
*/
|
|
47
|
+
export const withLeaseLockUsing = (acquire, release, operation) => Effect.suspend(() => {
|
|
48
|
+
let releaseExit;
|
|
49
|
+
return Effect.acquireUseRelease(acquire, () => operation, lock => Effect.exit(release(lock)).pipe(Effect.tap(exit => Effect.sync(() => {
|
|
50
|
+
releaseExit = exit;
|
|
51
|
+
})), Effect.asVoid)).pipe(Effect.exit, Effect.flatMap((operationExit) => {
|
|
52
|
+
if (releaseExit === undefined || Exit.isSuccess(releaseExit)) {
|
|
53
|
+
return replayExit(operationExit);
|
|
54
|
+
}
|
|
55
|
+
if (Exit.isSuccess(operationExit))
|
|
56
|
+
return Effect.failCause(releaseExit.cause);
|
|
57
|
+
if (isUnexpected(operationExit.cause))
|
|
58
|
+
return replayExit(operationExit);
|
|
59
|
+
if (isUnexpected(releaseExit.cause))
|
|
60
|
+
return Effect.failCause(releaseExit.cause);
|
|
61
|
+
if (Option.isSome(Cause.findErrorOption(operationExit.cause)) &&
|
|
62
|
+
Option.isSome(Cause.findErrorOption(releaseExit.cause))) {
|
|
63
|
+
return Effect.fail(new LifecycleError({ message: 'sandbox operation and lease lock cleanup both failed' }));
|
|
64
|
+
}
|
|
65
|
+
return replayExit(operationExit);
|
|
66
|
+
}));
|
|
67
|
+
});
|
|
68
|
+
export const withLeaseLock = (worktree, leaseName, operation) => withLeaseLockUsing(acquireLeaseLock(worktree, leaseName), releaseLeaseLock, operation);
|
|
69
|
+
const decodeLease = (content) => Schema.decodeUnknownOption(LeaseRecordFromJson)(content).pipe(Option.map(normalizeLease));
|
|
70
|
+
export const readLease = (worktree, leaseName = 'default') => Effect.gen(function* () {
|
|
71
|
+
const fs = yield* FileSystem.FileSystem;
|
|
72
|
+
const file = yield* leaseFile(worktree, leaseName);
|
|
73
|
+
const exists = yield* fs.exists(file).pipe(Effect.orElseSucceed(() => false));
|
|
74
|
+
if (!exists)
|
|
75
|
+
return Option.none();
|
|
76
|
+
const content = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => ''));
|
|
77
|
+
return decodeLease(content).pipe(Option.filter(lease => lease.leaseName === leaseName));
|
|
78
|
+
});
|
|
79
|
+
export const writeLease = (lease) => Effect.gen(function* () {
|
|
80
|
+
const fs = yield* FileSystem.FileSystem;
|
|
81
|
+
const file = yield* leaseFile(lease.worktree, lease.leaseName);
|
|
82
|
+
const temporary = `${file}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
|
|
83
|
+
const content = yield* Schema.encodeEffect(LeaseFromJson)(lease).pipe(Effect.mapError(cause => new LeaseError({ message: `lease encoding failed: ${cause.message}` })));
|
|
84
|
+
yield* fs.writeFileString(temporary, `${content}\n`, { mode: 0o600 }).pipe(Effect.andThen(fs.chmod(temporary, 0o600)), Effect.andThen(fs.rename(temporary, file)), Effect.mapError(cause => new LeaseError({ message: `lease write failed: ${cause.message}` })));
|
|
85
|
+
});
|
|
86
|
+
export const removeLease = (worktree, leaseName = 'default') => Effect.gen(function* () {
|
|
87
|
+
const fs = yield* FileSystem.FileSystem;
|
|
88
|
+
const file = yield* leaseFile(worktree, leaseName);
|
|
89
|
+
const exists = yield* fs
|
|
90
|
+
.exists(file)
|
|
91
|
+
.pipe(Effect.mapError(() => new LeaseError({ message: 'cannot inspect lease cleanup target' })));
|
|
92
|
+
if (!exists)
|
|
93
|
+
return;
|
|
94
|
+
yield* fs
|
|
95
|
+
.remove(file)
|
|
96
|
+
.pipe(Effect.mapError(() => new LeaseError({ message: 'cannot remove lease record' })));
|
|
97
|
+
const remains = yield* fs
|
|
98
|
+
.exists(file)
|
|
99
|
+
.pipe(Effect.mapError(() => new LeaseError({ message: 'cannot confirm lease record cleanup' })));
|
|
100
|
+
if (remains) {
|
|
101
|
+
return yield* new LeaseError({ message: 'lease record cleanup could not be confirmed' });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
export const allLeases = Effect.gen(function* () {
|
|
105
|
+
const fs = yield* FileSystem.FileSystem;
|
|
106
|
+
const path = yield* Path.Path;
|
|
107
|
+
const directory = yield* leaseDirectory;
|
|
108
|
+
const entries = yield* fs
|
|
109
|
+
.readDirectory(directory)
|
|
110
|
+
.pipe(Effect.orElseSucceed(() => []));
|
|
111
|
+
const leases = [];
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (!entry.endsWith('.json'))
|
|
114
|
+
continue;
|
|
115
|
+
const content = yield* fs
|
|
116
|
+
.readFileString(path.join(directory, entry))
|
|
117
|
+
.pipe(Effect.orElseSucceed(() => ''));
|
|
118
|
+
const decoded = decodeLease(content);
|
|
119
|
+
if (Option.isSome(decoded))
|
|
120
|
+
leases.push(decoded.value);
|
|
121
|
+
}
|
|
122
|
+
return leases.toSorted((a, b) => a.worktree.localeCompare(b.worktree) || a.leaseName.localeCompare(b.leaseName));
|
|
123
|
+
});
|
|
124
|
+
/**
|
|
125
|
+
* Whether a record's expiry passed more than `graceMs` ago. Every branch is created with a Neon
|
|
126
|
+
* `expires_at`, so Neon has already deleted such a branch and the record can be pruned without
|
|
127
|
+
* credentials.
|
|
128
|
+
*/
|
|
129
|
+
export const expiredBeyondGrace = (lease, now, graceMs) => {
|
|
130
|
+
const expires = Date.parse(lease.expiresAt);
|
|
131
|
+
return Number.isFinite(expires) && expires + graceMs < now;
|
|
132
|
+
};
|
package/dist/db/neon.js
ADDED
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { Cause, Effect, Exit, Option, Redacted, Result } from 'effect';
|
|
2
|
+
import { HttpClient, HttpClientRequest, HttpClientResponse } from 'effect/http';
|
|
3
|
+
import { BranchListResponse, BranchResponse, ConnectionUriResponse, CreateBranchResponse, DatabaseListResponse, LifecycleError, NeonError, ProjectResponse, READY_TIMEOUT_SECONDS, SUSPEND_TIMEOUT_SECONDS } from './domain.js';
|
|
4
|
+
import { branchMatchesExactIdentity } from './policy.js';
|
|
5
|
+
const API_ROOT = 'https://console.neon.tech/api/v2';
|
|
6
|
+
const request = (config, method, path, body) => Effect.gen(function* () {
|
|
7
|
+
const client = yield* HttpClient.HttpClient;
|
|
8
|
+
const base = HttpClientRequest.make(method)(`${API_ROOT}/projects/${config.projectId}${path}`).pipe(HttpClientRequest.setHeaders({
|
|
9
|
+
Authorization: `Bearer ${Redacted.value(config.apiKey)}`,
|
|
10
|
+
Accept: 'application/json'
|
|
11
|
+
}));
|
|
12
|
+
const prepared = body === undefined
|
|
13
|
+
? base
|
|
14
|
+
: yield* HttpClientRequest.bodyJson(base, body).pipe(Effect.mapError(() => new NeonError({ message: 'invalid request body' })));
|
|
15
|
+
return yield* client
|
|
16
|
+
.execute(prepared)
|
|
17
|
+
.pipe(Effect.mapError(() => new NeonError({ message: 'neon unreachable' })));
|
|
18
|
+
});
|
|
19
|
+
const decode = (schema, response) => HttpClientResponse.schemaBodyJson(schema)(response).pipe(Effect.mapError(() => new NeonError({ message: 'unexpected neon response shape; provider payload suppressed' })));
|
|
20
|
+
const failing = (response, action) => Effect.fail(new NeonError({
|
|
21
|
+
message: `${action} failed (${response.status}); provider response suppressed`
|
|
22
|
+
}));
|
|
23
|
+
const verifyProjectAccess = (config) => Effect.gen(function* () {
|
|
24
|
+
const response = yield* request(config, 'GET', '');
|
|
25
|
+
if (response.status === 401 || response.status === 403) {
|
|
26
|
+
return yield* new NeonError({ message: 'credential rejected by neon' });
|
|
27
|
+
}
|
|
28
|
+
if (response.status === 404) {
|
|
29
|
+
return yield* new NeonError({
|
|
30
|
+
message: `project ${config.projectId} not found, or this key is scoped to another project`
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (response.status >= 400)
|
|
34
|
+
return yield* failing(response, 'project lookup');
|
|
35
|
+
const body = yield* decode(ProjectResponse, response);
|
|
36
|
+
if (body.project.id !== config.projectId) {
|
|
37
|
+
return yield* new NeonError({
|
|
38
|
+
message: 'configured Neon project identity could not be attested'
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
return body.project;
|
|
42
|
+
});
|
|
43
|
+
/** Branch lookup that treats a re-verified project-scoped 404 as "gone". */
|
|
44
|
+
export const getBranch = (config, branchId) => Effect.gen(function* () {
|
|
45
|
+
const response = yield* request(config, 'GET', `/branches/${branchId}`);
|
|
46
|
+
if (response.status === 404) {
|
|
47
|
+
yield* verifyProjectAccess(config);
|
|
48
|
+
return Option.none();
|
|
49
|
+
}
|
|
50
|
+
if (response.status >= 400)
|
|
51
|
+
return yield* failing(response, 'branch lookup');
|
|
52
|
+
return Option.some((yield* decode(BranchResponse, response)).branch);
|
|
53
|
+
});
|
|
54
|
+
export const listBranches = (config) => Effect.gen(function* () {
|
|
55
|
+
const branches = [];
|
|
56
|
+
const seenCursors = new Set();
|
|
57
|
+
let cursor;
|
|
58
|
+
do {
|
|
59
|
+
const query = cursor === undefined ? '' : `?${new URLSearchParams({ cursor }).toString()}`;
|
|
60
|
+
const response = yield* request(config, 'GET', `/branches${query}`);
|
|
61
|
+
if (response.status >= 400)
|
|
62
|
+
return yield* failing(response, 'branch listing');
|
|
63
|
+
const body = yield* decode(BranchListResponse, response);
|
|
64
|
+
branches.push(...body.branches);
|
|
65
|
+
const next = body.pagination?.cursor;
|
|
66
|
+
if (next !== undefined && seenCursors.has(next)) {
|
|
67
|
+
return yield* new NeonError({ message: 'branch pagination cursor cycle detected' });
|
|
68
|
+
}
|
|
69
|
+
if (next !== undefined)
|
|
70
|
+
seenCursors.add(next);
|
|
71
|
+
cursor = next;
|
|
72
|
+
} while (cursor !== undefined);
|
|
73
|
+
return branches;
|
|
74
|
+
});
|
|
75
|
+
export const listDatabases = (config, branchId) => Effect.gen(function* () {
|
|
76
|
+
const response = yield* request(config, 'GET', `/branches/${branchId}/databases`);
|
|
77
|
+
if (response.status >= 400)
|
|
78
|
+
return yield* failing(response, 'database listing');
|
|
79
|
+
return (yield* decode(DatabaseListResponse, response)).databases;
|
|
80
|
+
});
|
|
81
|
+
const retrieveConnectionUri = (config, branchId, target, pooled) => Effect.gen(function* () {
|
|
82
|
+
const query = new URLSearchParams({
|
|
83
|
+
branch_id: branchId,
|
|
84
|
+
database_name: target.databaseName,
|
|
85
|
+
role_name: target.roleName,
|
|
86
|
+
pooled: String(pooled)
|
|
87
|
+
});
|
|
88
|
+
const response = yield* request(config, 'GET', `/connection_uri?${query.toString()}`);
|
|
89
|
+
if (response.status >= 400)
|
|
90
|
+
return yield* failing(response, 'connection URI retrieval');
|
|
91
|
+
return (yield* decode(ConnectionUriResponse, response)).uri;
|
|
92
|
+
});
|
|
93
|
+
export const getConnectionUris = (config, branchId, recordedTarget) => Effect.gen(function* () {
|
|
94
|
+
const target = recordedTarget ??
|
|
95
|
+
(yield* listDatabases(config, branchId).pipe(Effect.flatMap(databases => {
|
|
96
|
+
const database = databases[0];
|
|
97
|
+
return databases.length === 1 && database !== undefined
|
|
98
|
+
? Effect.succeed({ databaseName: database.name, roleName: database.owner_name })
|
|
99
|
+
: Effect.fail(new NeonError({
|
|
100
|
+
message: `cannot restore connection URLs for branch ${branchId}: ` +
|
|
101
|
+
`expected one database, found ${databases.length}`
|
|
102
|
+
}));
|
|
103
|
+
})));
|
|
104
|
+
const [direct, pooled] = yield* Effect.all([
|
|
105
|
+
retrieveConnectionUri(config, branchId, target, false),
|
|
106
|
+
retrieveConnectionUri(config, branchId, target, true)
|
|
107
|
+
], { concurrency: 2 });
|
|
108
|
+
return { ...target, direct, pooled };
|
|
109
|
+
});
|
|
110
|
+
/** Validates a credential against one project and returns the project. */
|
|
111
|
+
export const describeProject = (config) => verifyProjectAccess(config);
|
|
112
|
+
/**
|
|
113
|
+
* Attests a profile against what `provision factory` creates for an `empty-baseline` repository:
|
|
114
|
+
* the project is the repository's `<repo>-dev` project and the parent is its protected default
|
|
115
|
+
* `baseline` branch, rather than an older profile silently trusted by ID.
|
|
116
|
+
*/
|
|
117
|
+
export const attestSandboxProfile = (config, repository) => Effect.gen(function* () {
|
|
118
|
+
const repositoryName = repository.split('/').pop() ?? repository;
|
|
119
|
+
const expectedProjectName = `${repositoryName}-dev`;
|
|
120
|
+
const project = yield* verifyProjectAccess(config);
|
|
121
|
+
if (project.name !== expectedProjectName) {
|
|
122
|
+
return yield* new NeonError({
|
|
123
|
+
message: `configured Neon project must be named ${expectedProjectName}; ` +
|
|
124
|
+
'align or explicitly import older sandbox baselines before routine mutation'
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
const parent = yield* getBranch(config, config.parentBranch);
|
|
128
|
+
if (Option.isNone(parent)) {
|
|
129
|
+
return yield* new NeonError({ message: 'configured Neon parent baseline does not exist' });
|
|
130
|
+
}
|
|
131
|
+
if (parent.value.id !== config.parentBranch ||
|
|
132
|
+
parent.value.name !== 'baseline' ||
|
|
133
|
+
parent.value.default !== true ||
|
|
134
|
+
parent.value.protected !== true) {
|
|
135
|
+
return yield* new NeonError({
|
|
136
|
+
message: 'configured Neon parent must be the protected default baseline; ' +
|
|
137
|
+
'align or explicitly import older sandbox baselines before routine mutation'
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
return { project, parent: parent.value };
|
|
141
|
+
});
|
|
142
|
+
/** Resolves the default parent when global auth does not name one explicitly. */
|
|
143
|
+
export const findDefaultBranch = (config) => listBranches(config).pipe(Effect.flatMap(branches => {
|
|
144
|
+
const found = branches.find(branch => branch.default === true);
|
|
145
|
+
return found === undefined
|
|
146
|
+
? Effect.fail(new NeonError({ message: 'project has no default branch' }))
|
|
147
|
+
: Effect.succeed(found);
|
|
148
|
+
}));
|
|
149
|
+
const unsafeCreateOutcome = () => new NeonError({
|
|
150
|
+
message: 'branch creation outcome could not be safely proven; inspect the uniquely named requested agent branch in Neon and remove it manually if appropriate'
|
|
151
|
+
});
|
|
152
|
+
const settleDelay = '100 millis';
|
|
153
|
+
const recoverUnknownCreateOutcome = (config, options, beforeIds) => Effect.gen(function* () {
|
|
154
|
+
let matches = [];
|
|
155
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
156
|
+
const listed = yield* Effect.result(listBranches(config));
|
|
157
|
+
if (Result.isFailure(listed))
|
|
158
|
+
return yield* unsafeCreateOutcome();
|
|
159
|
+
matches = listed.success.filter(branch => branch.name === options.name);
|
|
160
|
+
if (matches.length > 0)
|
|
161
|
+
break;
|
|
162
|
+
if (attempt < 2)
|
|
163
|
+
yield* Effect.sleep(settleDelay);
|
|
164
|
+
}
|
|
165
|
+
const candidate = matches.length === 1 ? matches[0] : undefined;
|
|
166
|
+
if (candidate === undefined || beforeIds.has(candidate.id)) {
|
|
167
|
+
return yield* unsafeCreateOutcome();
|
|
168
|
+
}
|
|
169
|
+
const expected = {
|
|
170
|
+
branchId: candidate.id,
|
|
171
|
+
branchName: options.name,
|
|
172
|
+
parentBranch: config.parentBranch
|
|
173
|
+
};
|
|
174
|
+
if (!branchMatchesExactIdentity(expected, candidate, options.expiresAt)) {
|
|
175
|
+
return yield* unsafeCreateOutcome();
|
|
176
|
+
}
|
|
177
|
+
yield* deleteVerifiedBranch(config, expected, options.expiresAt).pipe(Effect.mapError(() => new LifecycleError({
|
|
178
|
+
message: 'unknown branch creation outcome and verified cleanup both failed; inspect the uniquely named requested agent branch in Neon and remove it manually if appropriate'
|
|
179
|
+
})));
|
|
180
|
+
});
|
|
181
|
+
/**
|
|
182
|
+
* Creates an unprotected agent branch and its connection URLs. Every path that may have created a
|
|
183
|
+
* branch without returning it proves and removes exactly that branch, or reports manual recovery.
|
|
184
|
+
*/
|
|
185
|
+
export const createBranch = (config, options) => Effect.gen(function* () {
|
|
186
|
+
const before = yield* listBranches(config);
|
|
187
|
+
const beforeIds = new Set(before.map(branch => branch.id));
|
|
188
|
+
if (before.some(branch => branch.name === options.name)) {
|
|
189
|
+
return yield* unsafeCreateOutcome();
|
|
190
|
+
}
|
|
191
|
+
return yield* Effect.uninterruptibleMask(restore => Effect.gen(function* () {
|
|
192
|
+
const posted = yield* Effect.exit(Effect.gen(function* () {
|
|
193
|
+
const response = yield* request(config, 'POST', '/branches', {
|
|
194
|
+
branch: {
|
|
195
|
+
name: options.name,
|
|
196
|
+
parent_id: config.parentBranch,
|
|
197
|
+
expires_at: options.expiresAt,
|
|
198
|
+
protected: false
|
|
199
|
+
},
|
|
200
|
+
endpoints: [{ type: 'read_write', suspend_timeout_seconds: SUSPEND_TIMEOUT_SECONDS }]
|
|
201
|
+
});
|
|
202
|
+
if (response.status >= 400)
|
|
203
|
+
return yield* failing(response, 'branch creation');
|
|
204
|
+
const body = yield* decode(CreateBranchResponse, response);
|
|
205
|
+
const expected = {
|
|
206
|
+
branchId: body.branch.id,
|
|
207
|
+
branchName: options.name,
|
|
208
|
+
parentBranch: config.parentBranch
|
|
209
|
+
};
|
|
210
|
+
if (!branchMatchesExactIdentity(expected, body.branch, options.expiresAt)) {
|
|
211
|
+
return yield* unsafeCreateOutcome();
|
|
212
|
+
}
|
|
213
|
+
return expected;
|
|
214
|
+
}));
|
|
215
|
+
if (Exit.isFailure(posted)) {
|
|
216
|
+
const cleanupExit = yield* Effect.exit(recoverUnknownCreateOutcome(config, options, beforeIds));
|
|
217
|
+
if (Exit.isFailure(cleanupExit)) {
|
|
218
|
+
return yield* new LifecycleError({
|
|
219
|
+
message: 'branch creation outcome could not be safely proven and verified cleanup failed; inspect the uniquely named requested agent branch in Neon and remove it manually if appropriate'
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return yield* Effect.failCause(posted.cause);
|
|
223
|
+
}
|
|
224
|
+
const expected = posted.value;
|
|
225
|
+
const observationExit = yield* Effect.exit(Effect.gen(function* () {
|
|
226
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
227
|
+
const fresh = yield* Effect.result(getBranch(config, expected.branchId));
|
|
228
|
+
if (Result.isSuccess(fresh) &&
|
|
229
|
+
Option.isSome(fresh.success) &&
|
|
230
|
+
branchMatchesExactIdentity(expected, fresh.success.value, options.expiresAt)) {
|
|
231
|
+
return fresh.success.value;
|
|
232
|
+
}
|
|
233
|
+
if (attempt < 2)
|
|
234
|
+
yield* Effect.sleep(settleDelay);
|
|
235
|
+
}
|
|
236
|
+
return yield* unsafeCreateOutcome();
|
|
237
|
+
}));
|
|
238
|
+
if (Exit.isFailure(observationExit)) {
|
|
239
|
+
const cleanupExit = yield* Effect.exit(recoverUnknownCreateOutcome(config, options, beforeIds));
|
|
240
|
+
if (Exit.isFailure(cleanupExit))
|
|
241
|
+
return yield* Effect.failCause(cleanupExit.cause);
|
|
242
|
+
return yield* Effect.failCause(observationExit.cause);
|
|
243
|
+
}
|
|
244
|
+
const connectionExit = yield* Effect.exit(restore(getConnectionUris(config, expected.branchId)));
|
|
245
|
+
if (Exit.isFailure(connectionExit)) {
|
|
246
|
+
const cleanupExit = yield* Effect.exit(deleteVerifiedBranch(config, expected, options.expiresAt));
|
|
247
|
+
if (Exit.isFailure(cleanupExit)) {
|
|
248
|
+
if (Cause.hasInterrupts(connectionExit.cause) || Cause.hasDies(connectionExit.cause)) {
|
|
249
|
+
return yield* Effect.failCause(connectionExit.cause);
|
|
250
|
+
}
|
|
251
|
+
return yield* new LifecycleError({
|
|
252
|
+
message: 'new branch setup and verified cleanup both failed; inspect the requested agent branch in Neon and remove it manually if appropriate'
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return yield* Effect.failCause(connectionExit.cause);
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
branch: observationExit.value,
|
|
259
|
+
pooled: connectionExit.value.pooled,
|
|
260
|
+
direct: connectionExit.value.direct,
|
|
261
|
+
databaseName: connectionExit.value.databaseName,
|
|
262
|
+
roleName: connectionExit.value.roleName
|
|
263
|
+
};
|
|
264
|
+
}));
|
|
265
|
+
});
|
|
266
|
+
const unverifiedExpiration = () => new NeonError({
|
|
267
|
+
message: 'expiration update could not be safely verified; inspect the recorded agent branch in Neon before retrying'
|
|
268
|
+
});
|
|
269
|
+
export const setExpiration = (config, expected, expiresAt) => Effect.gen(function* () {
|
|
270
|
+
const response = yield* request(config, 'PATCH', `/branches/${expected.branchId}`, {
|
|
271
|
+
branch: { expires_at: expiresAt }
|
|
272
|
+
});
|
|
273
|
+
if (response.status >= 400)
|
|
274
|
+
return yield* failing(response, 'expiration update');
|
|
275
|
+
const body = yield* decode(BranchResponse, response);
|
|
276
|
+
if (!branchMatchesExactIdentity(expected, body.branch, expiresAt)) {
|
|
277
|
+
return yield* unverifiedExpiration();
|
|
278
|
+
}
|
|
279
|
+
const observed = yield* getBranch(config, expected.branchId).pipe(Effect.mapError(unverifiedExpiration));
|
|
280
|
+
if (Option.isNone(observed)) {
|
|
281
|
+
return yield* new NeonError({
|
|
282
|
+
message: 'recorded branch disappeared after expiration update'
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
if (!branchMatchesExactIdentity(expected, observed.value, expiresAt)) {
|
|
286
|
+
return yield* unverifiedExpiration();
|
|
287
|
+
}
|
|
288
|
+
return observed.value;
|
|
289
|
+
});
|
|
290
|
+
const deleteBranch = (config, branchId) => Effect.gen(function* () {
|
|
291
|
+
const response = yield* request(config, 'DELETE', `/branches/${branchId}`);
|
|
292
|
+
if (response.status === 404) {
|
|
293
|
+
yield* verifyProjectAccess(config);
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (response.status >= 400)
|
|
297
|
+
return yield* failing(response, 'branch deletion');
|
|
298
|
+
});
|
|
299
|
+
export const confirmStableBranchAbsence = (config, branchId) => Effect.gen(function* () {
|
|
300
|
+
for (let observation = 0; observation < 2; observation += 1) {
|
|
301
|
+
if (Option.isSome(yield* getBranch(config, branchId)))
|
|
302
|
+
return false;
|
|
303
|
+
if (observation === 0)
|
|
304
|
+
yield* Effect.sleep(settleDelay);
|
|
305
|
+
}
|
|
306
|
+
return true;
|
|
307
|
+
});
|
|
308
|
+
/**
|
|
309
|
+
* Deletes the branch only after re-observing its exact identity, then confirms stable absence.
|
|
310
|
+
* Returns `false` when the branch was already gone.
|
|
311
|
+
*/
|
|
312
|
+
export const deleteVerifiedBranch = (config, expected, expiresAt) => Effect.gen(function* () {
|
|
313
|
+
const observed = yield* getBranch(config, expected.branchId);
|
|
314
|
+
if (Option.isNone(observed)) {
|
|
315
|
+
if (!(yield* confirmStableBranchAbsence(config, expected.branchId))) {
|
|
316
|
+
return yield* new NeonError({
|
|
317
|
+
message: 'branch absence was not stable; manual review is required'
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return false;
|
|
321
|
+
}
|
|
322
|
+
if (!branchMatchesExactIdentity(expected, observed.value, expiresAt)) {
|
|
323
|
+
return yield* new NeonError({
|
|
324
|
+
message: 'branch deletion target failed strict identity validation'
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
yield* deleteBranch(config, expected.branchId);
|
|
328
|
+
if (!(yield* confirmStableBranchAbsence(config, expected.branchId))) {
|
|
329
|
+
return yield* new NeonError({
|
|
330
|
+
message: 'branch deletion could not be confirmed; manual review is required'
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
return true;
|
|
334
|
+
});
|
|
335
|
+
const settled = (state) => state === 'ready' || state === 'missing';
|
|
336
|
+
/** Polls until the compute is usable; a timeout is reported as `not-ready`, never thrown away. */
|
|
337
|
+
export const waitUntilReady = (config, branchId) => {
|
|
338
|
+
const currentState = getBranch(config, branchId).pipe(Effect.map(Option.match({
|
|
339
|
+
onNone: () => 'missing',
|
|
340
|
+
onSome: branch => branch.current_state ?? 'unknown'
|
|
341
|
+
})));
|
|
342
|
+
return Effect.gen(function* () {
|
|
343
|
+
let state = yield* currentState;
|
|
344
|
+
while (!settled(state)) {
|
|
345
|
+
yield* Effect.sleep('2 seconds');
|
|
346
|
+
state = yield* currentState;
|
|
347
|
+
}
|
|
348
|
+
return state;
|
|
349
|
+
}).pipe(Effect.timeout(`${READY_TIMEOUT_SECONDS} seconds`), Effect.orElseSucceed(() => 'not-ready'));
|
|
350
|
+
};
|