@sequenceholdings/studio-cli 0.1.24 → 0.1.25
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 +24 -3
- package/dist/auth.d.ts +9 -5
- package/dist/auth.js +121 -8
- package/dist/config.d.ts +7 -3
- package/dist/config.js +77 -5
- package/dist/envs/commands.js +9 -6
- package/dist/functions/bundle.js +3 -3
- package/dist/functions/commands.js +18 -5
- package/dist/functions/manifest.d.ts +3 -0
- package/dist/functions/manifest.js +48 -0
- package/dist/login.js +3 -3
- package/dist/pipeline/commands.js +6 -2
- package/dist/pipeline/lifecycle.js +93 -16
- package/dist/preview.d.ts +4 -3
- package/dist/preview.js +5 -4
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -405,6 +405,27 @@ limits:
|
|
|
405
405
|
`min_instances` defaults to `0`, cannot exceed `max_instances`, and incurs
|
|
406
406
|
Cloud Run idle-instance charges while warm.
|
|
407
407
|
|
|
408
|
+
### Invocation IP allowlist
|
|
409
|
+
|
|
410
|
+
Declare the function-level source-IP restriction in `managed-function.yml`:
|
|
411
|
+
|
|
412
|
+
```yaml
|
|
413
|
+
invocation:
|
|
414
|
+
allowed_ip_ranges:
|
|
415
|
+
- 203.0.113.0/24
|
|
416
|
+
- 2001:db8::/48
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
`seq-studio functions build` validates IPv4/IPv6 addresses and CIDRs locally;
|
|
420
|
+
Atlas performs authoritative normalization during upload. `/0` ranges are
|
|
421
|
+
rejected. Omitting `invocation` or declaring `allowed_ip_ranges: []` means
|
|
422
|
+
unrestricted source IPs.
|
|
423
|
+
|
|
424
|
+
The policy is part of the immutable function version. It becomes live atomically
|
|
425
|
+
when that version activates, a failed deploy leaves the current policy unchanged,
|
|
426
|
+
and promote or rollback restores the selected version's policy. The gateway
|
|
427
|
+
checks a non-empty policy before invocation permissions.
|
|
428
|
+
|
|
408
429
|
### Server-owned resource authorization
|
|
409
430
|
|
|
410
431
|
Some functions expose regulated external resources whose authorization must be
|
|
@@ -632,8 +653,8 @@ install it alongside the CLI to use this family.
|
|
|
632
653
|
|---------|--------------|
|
|
633
654
|
| `seq-studio pipeline init --type ingestion\|transformation\|serving <name> [--dir <dir>]` | Scaffold `<name>.stage.yml` (commented per-kind template) plus a `src/` Databricks-notebook entrypoint stub (begins with `# Databricks notebook source`; serving stages are declarative — no stub). Refuses to overwrite an existing spec |
|
|
634
655
|
| `seq-studio pipeline validate [dir] [--assets <file\|url>] [--orm-contracts <file\|url>] [--json]` | Run the full offline spec gate: envelope + body validation, `schema_ref` resolution, and repo-level graph validation (reference resolution, single-writer, cycles, column subsets, serving projection checks). Auto-loads `orm-contracts.json` from the pipeline dir when present. Exit 0/1 |
|
|
635
|
-
| `seq-studio pipeline plan --repo pipelines/<slug> --ref <sha\|branch> -e <env> [--target <id>] [--json]` |
|
|
636
|
-
| `seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha\|branch> -e <env> [--target <id>] [--approved-by <sub>] [--no-wait]` | Plan then enqueue deploy; Trigger runs `bundle validate` then `bundle deploy` against reviewed bytes. Polls to terminal unless `--no-wait`. Targets that require approval need `--approved-by` naming the authenticated caller. |
|
|
656
|
+
| `seq-studio pipeline plan --repo pipelines/<slug> --ref <sha\|branch> -e <env> [--target <id>] [--json]` | Enqueue and poll a durable Pipeline plan (materialize → SDK/`validateSpecGraph` → compile → live-diff → provision findings). Fails closed listing **every** missing target binding (alert channels, workspace, Databricks `source.credential`) plus Data Sync edge-worker machine/operation/`credEnvFamilies` mismatches before registry writes. Does **not** run Databricks `bundle validate` (that is a Trigger deploy-path hard gate). Exit 1 on destructive findings (CI-safe). `--json` emits the completed stable plan envelope |
|
|
657
|
+
| `seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha\|branch> -e <env> [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait]` | Plan then enqueue deploy; Trigger runs `bundle validate` then `bundle deploy` against reviewed bytes. Default is plant-only (jobs are created, not run). `--run-now` runs in-unit producer roots after bundle deploy and waits before serving sync create. Polls to terminal unless `--no-wait`. Targets that require approval need `--approved-by` naming the authenticated caller. |
|
|
637
658
|
| `seq-studio pipeline adopt --stage <slug> --ref <sha\|branch> -e <env> [--target <id>] --native-id <id> --approved-by <you> [--resource-key <key>] [--kind job\|dlt_pipeline] [--old-source-removal-pr <url>] [--repo pipelines/<slug>]` | Bind a live Databricks job/pipeline into the stage without recreation (`bundle deployment bind` on Trigger). Always requires `--approved-by` naming the caller. When the key is still in the monorepo DAB, pass `--old-source-removal-pr` and follow the returned cutover checklist: unbind the old bundle state without deleting the remote, then remove its DAB declaration and add the target-specific adopted-resource entry in the same PR before redeploying. |
|
|
638
659
|
| `seq-studio pipeline unbind --stage <slug> --ref <sha\|branch> -e <env> [--target <id>] --approved-by <you> [--resource-key <key>] [--repo pipelines/<slug>]` | Release an adopted binding on Trigger; the remote object stays live (never deleted) |
|
|
639
660
|
| `seq-studio pipeline run-now --stage <slug> -e <env> [--target <id>] [--repo pipelines/<slug>] [--json]` | Run the stage's active job or DLT pipeline immediately and print its Databricks run URL |
|
|
@@ -644,7 +665,7 @@ install it alongside the CLI to use this family.
|
|
|
644
665
|
pipeline target advertised by that endpoint. It is optional when the alias
|
|
645
666
|
matches a target id or when the endpoint has exactly one target.
|
|
646
667
|
|
|
647
|
-
Unless `--no-wait` is set, `deploy`, `promote`, and `rollback` report status or
|
|
668
|
+
Unless `--no-wait` is set, `plan`, `deploy`, `promote`, and `rollback` report status or
|
|
648
669
|
status-detail changes while waiting, then emit a 20-second progress heartbeat.
|
|
649
670
|
Terminal output includes the deployment ID and elapsed time; failures include
|
|
650
671
|
the status detail, and any available Trigger run ID is shown. `--json` output
|
package/dist/auth.d.ts
CHANGED
|
@@ -53,14 +53,17 @@ export interface SeqapiTokens {
|
|
|
53
53
|
access_token?: string;
|
|
54
54
|
expires_at?: number;
|
|
55
55
|
}
|
|
56
|
+
interface RealmResolutionOptions {
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
targetUrl?: string;
|
|
59
|
+
}
|
|
56
60
|
/**
|
|
57
61
|
* Resolve the auth realm for an environment name. Undefined, built-ins, and
|
|
58
|
-
* per-PR preview targets map to the shared Sequence realm
|
|
59
|
-
*
|
|
60
|
-
* other explicit name must have a valid seqapi registry entry
|
|
61
|
-
* closed so a shared Sequence bearer token can never be sent to a tenant URL.
|
|
62
|
+
* per-PR preview targets map to the shared Sequence realm, except loopback
|
|
63
|
+
* targets can advertise the isolated Sequence-staging realm used by contractor
|
|
64
|
+
* local dev. Every other explicit name must have a valid seqapi registry entry.
|
|
62
65
|
*/
|
|
63
|
-
export declare function realmForEnv(envName?: string): Promise<AuthRealm>;
|
|
66
|
+
export declare function realmForEnv(envName?: string, options?: RealmResolutionOptions): Promise<AuthRealm>;
|
|
64
67
|
/** Env var(s) carrying a realm's M2M client secret — the Sequence realm keeps
|
|
65
68
|
* the legacy bare name; OpCo realms use a suffixed name so one shell can hold
|
|
66
69
|
* several credentials unambiguously. Mirrors seqapi's `_m2m_secret_env_names`. */
|
|
@@ -153,3 +156,4 @@ export declare function currentIdentitySubject(options?: {
|
|
|
153
156
|
export declare function loadCachedUserTokens(realmName?: string): Promise<SeqapiTokens | null>;
|
|
154
157
|
export declare function saveTokens(tokens: SeqapiTokens, realmName?: string): Promise<void>;
|
|
155
158
|
export declare function deleteRealmTokens(realmName: string): Promise<void>;
|
|
159
|
+
export {};
|
package/dist/auth.js
CHANGED
|
@@ -40,6 +40,12 @@ const AUTH0_M2M_CLIENT_ID = '5TLffZqvLq4ztLDjZhwKVJCpB5VNrWj0';
|
|
|
40
40
|
* also the implicit realm of the legacy flat fields in tokens.json. */
|
|
41
41
|
export const SEQUENCE_REALM = 'sequence';
|
|
42
42
|
const SEQUENCE_BUILTIN_ENVS = new Set(['local', 'staging', 'production', 'banksouth']);
|
|
43
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
|
|
44
|
+
const SEQUENCE_STAGING_AUTH0_AUDIENCE = 'https://staging.sequence.seqholdings.com/api';
|
|
45
|
+
const TRUSTED_LOOPBACK_AUTH0_AUDIENCES = new Set([
|
|
46
|
+
AUTH0_AUDIENCE,
|
|
47
|
+
SEQUENCE_STAGING_AUTH0_AUDIENCE,
|
|
48
|
+
]);
|
|
43
49
|
export function isSequenceAuthEnvName(envName) {
|
|
44
50
|
return (envName === SEQUENCE_REALM ||
|
|
45
51
|
SEQUENCE_BUILTIN_ENVS.has(envName) ||
|
|
@@ -76,14 +82,116 @@ export const SEQUENCE_AUTH_REALM = {
|
|
|
76
82
|
function seqapiConfigPath() {
|
|
77
83
|
return join(seqapiTokenDir(), 'config.json');
|
|
78
84
|
}
|
|
85
|
+
function isRecord(value) {
|
|
86
|
+
return typeof value === 'object' && value !== null;
|
|
87
|
+
}
|
|
88
|
+
function isLoopbackEnvName(envName) {
|
|
89
|
+
return envName === 'local' || envName.startsWith('local:');
|
|
90
|
+
}
|
|
91
|
+
function loopbackTargetUrl({ configuredTargetUrl, envName, }) {
|
|
92
|
+
if (configuredTargetUrl)
|
|
93
|
+
return configuredTargetUrl;
|
|
94
|
+
return envName === 'local' ? 'http://localhost:5001' : undefined;
|
|
95
|
+
}
|
|
96
|
+
function optionalString({ field, value, }) {
|
|
97
|
+
if (value === undefined || value === null)
|
|
98
|
+
return undefined;
|
|
99
|
+
if (typeof value === 'string')
|
|
100
|
+
return value;
|
|
101
|
+
throw new Error(`Local Auth0 discovery returned invalid '${field}'.`);
|
|
102
|
+
}
|
|
103
|
+
function parseDiscoveredAuth0(payload) {
|
|
104
|
+
const auth0 = isRecord(payload) ? Reflect.get(payload, 'auth0') : undefined;
|
|
105
|
+
const auth0Record = isRecord(auth0) ? auth0 : {};
|
|
106
|
+
const domain = Reflect.get(auth0Record, 'domain');
|
|
107
|
+
const clientId = Reflect.get(auth0Record, 'clientId');
|
|
108
|
+
const audience = Reflect.get(auth0Record, 'audience');
|
|
109
|
+
if (typeof domain !== 'string' ||
|
|
110
|
+
!domain ||
|
|
111
|
+
typeof clientId !== 'string' ||
|
|
112
|
+
!clientId ||
|
|
113
|
+
typeof audience !== 'string' ||
|
|
114
|
+
!audience) {
|
|
115
|
+
throw new Error('Local Auth0 discovery returned incomplete required config.');
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
domain,
|
|
119
|
+
clientId,
|
|
120
|
+
audience,
|
|
121
|
+
organization: optionalString({
|
|
122
|
+
field: 'organization',
|
|
123
|
+
value: Reflect.get(auth0Record, 'organization'),
|
|
124
|
+
}),
|
|
125
|
+
m2mClientId: optionalString({
|
|
126
|
+
field: 'm2mClientId',
|
|
127
|
+
value: Reflect.get(auth0Record, 'm2mClientId'),
|
|
128
|
+
}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function discoverLoopbackRealm({ envName, fetchImpl = fetch, targetUrl, }) {
|
|
132
|
+
const baseUrl = validateDeploymentBaseUrl(targetUrl);
|
|
133
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
134
|
+
if (!LOOPBACK_HOSTNAMES.has(hostname))
|
|
135
|
+
return SEQUENCE_AUTH_REALM;
|
|
136
|
+
let response;
|
|
137
|
+
try {
|
|
138
|
+
response = await fetchImpl(`${baseUrl}/api/auth/cli-config`, {
|
|
139
|
+
redirect: 'error',
|
|
140
|
+
signal: AbortSignal.timeout(2_000),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Preserve the historical realm choice while local Atlas is stopped. The
|
|
145
|
+
// subsequent API request reports that the local server is unavailable.
|
|
146
|
+
return SEQUENCE_AUTH_REALM;
|
|
147
|
+
}
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new Error(`Could not discover Auth0 config for local environment '${envName}': ` +
|
|
150
|
+
`${baseUrl}/api/auth/cli-config returned HTTP ${response.status}.`);
|
|
151
|
+
}
|
|
152
|
+
const payload = await response.json();
|
|
153
|
+
const { audience, clientId, domain, m2mClientId, organization } = parseDiscoveredAuth0(payload);
|
|
154
|
+
if (!TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(audience)) {
|
|
155
|
+
throw new Error(`Untrusted Auth0 audience '${audience}' for local environment '${envName}'.`);
|
|
156
|
+
}
|
|
157
|
+
const trustedDomain = validateAuth0Domain(domain);
|
|
158
|
+
if (clientId === AUTH0_CLIENT_ID && audience === AUTH0_AUDIENCE) {
|
|
159
|
+
return SEQUENCE_AUTH_REALM;
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
name: 'local',
|
|
163
|
+
domain: trustedDomain,
|
|
164
|
+
clientId,
|
|
165
|
+
audience,
|
|
166
|
+
organization,
|
|
167
|
+
m2mClientId,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
async function maybeDiscoverLoopbackRealm({ envName, options, }) {
|
|
171
|
+
if (!envName || !isLoopbackEnvName(envName))
|
|
172
|
+
return undefined;
|
|
173
|
+
const targetUrl = loopbackTargetUrl({
|
|
174
|
+
configuredTargetUrl: options.targetUrl,
|
|
175
|
+
envName,
|
|
176
|
+
});
|
|
177
|
+
if (!targetUrl)
|
|
178
|
+
return undefined;
|
|
179
|
+
return discoverLoopbackRealm({
|
|
180
|
+
envName,
|
|
181
|
+
fetchImpl: options.fetchImpl,
|
|
182
|
+
targetUrl,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
79
185
|
/**
|
|
80
186
|
* Resolve the auth realm for an environment name. Undefined, built-ins, and
|
|
81
|
-
* per-PR preview targets map to the shared Sequence realm
|
|
82
|
-
*
|
|
83
|
-
* other explicit name must have a valid seqapi registry entry
|
|
84
|
-
* closed so a shared Sequence bearer token can never be sent to a tenant URL.
|
|
187
|
+
* per-PR preview targets map to the shared Sequence realm, except loopback
|
|
188
|
+
* targets can advertise the isolated Sequence-staging realm used by contractor
|
|
189
|
+
* local dev. Every other explicit name must have a valid seqapi registry entry.
|
|
85
190
|
*/
|
|
86
|
-
export async function realmForEnv(envName) {
|
|
191
|
+
export async function realmForEnv(envName, options = {}) {
|
|
192
|
+
const discoveredRealm = await maybeDiscoverLoopbackRealm({ envName, options });
|
|
193
|
+
if (discoveredRealm)
|
|
194
|
+
return discoveredRealm;
|
|
87
195
|
if (!envName || isSequenceAuthEnvName(envName)) {
|
|
88
196
|
return SEQUENCE_AUTH_REALM;
|
|
89
197
|
}
|
|
@@ -303,7 +411,12 @@ function validateRealmTarget({ realm, targetUrl, }) {
|
|
|
303
411
|
try {
|
|
304
412
|
const baseUrl = validateDeploymentBaseUrl(targetUrl);
|
|
305
413
|
if (realm.name !== SEQUENCE_REALM) {
|
|
306
|
-
|
|
414
|
+
const hostname = new URL(baseUrl).hostname.toLowerCase();
|
|
415
|
+
const isReviewedLoopbackRealm = LOOPBACK_HOSTNAMES.has(hostname) &&
|
|
416
|
+
TRUSTED_LOOPBACK_AUTH0_AUDIENCES.has(realm.audience);
|
|
417
|
+
if (!isReviewedLoopbackRealm) {
|
|
418
|
+
validateDeploymentAudience({ audience: realm.audience, baseUrl });
|
|
419
|
+
}
|
|
307
420
|
}
|
|
308
421
|
}
|
|
309
422
|
catch (error) {
|
|
@@ -312,7 +425,7 @@ function validateRealmTarget({ realm, targetUrl, }) {
|
|
|
312
425
|
}
|
|
313
426
|
}
|
|
314
427
|
export async function getAccessTokenWithMode(options) {
|
|
315
|
-
const realm = await realmForEnv(options?.env);
|
|
428
|
+
const realm = await realmForEnv(options?.env, { targetUrl: options?.targetUrl });
|
|
316
429
|
if (options?.targetUrl)
|
|
317
430
|
validateRealmTarget({ realm, targetUrl: options.targetUrl });
|
|
318
431
|
const forceM2m = authModePrefersM2m();
|
|
@@ -377,7 +490,7 @@ export async function tryGetAccessTokenWithMode(options) {
|
|
|
377
490
|
// Do not replace an invalid tenant registration with the Sequence realm:
|
|
378
491
|
// that could hide a rejected discovery token host or inspect the wrong
|
|
379
492
|
// M2M secret. Config-resolution errors must surface unchanged.
|
|
380
|
-
const realm = await realmForEnv(options.env);
|
|
493
|
+
const realm = await realmForEnv(options.env, { targetUrl: options.targetUrl });
|
|
381
494
|
if (process.env[m2mSecretEnvName(realm)]?.trim())
|
|
382
495
|
throw err;
|
|
383
496
|
}
|
package/dist/config.d.ts
CHANGED
|
@@ -24,14 +24,18 @@ export declare function defaultConfig(): LatticeConfig;
|
|
|
24
24
|
* Read the effective config. Merge precedence (later wins):
|
|
25
25
|
*
|
|
26
26
|
* 1. built-in `local`
|
|
27
|
-
* 2.
|
|
28
|
-
* 3.
|
|
29
|
-
* 4.
|
|
27
|
+
* 2. local worktrees discovered from `.wt.env`
|
|
28
|
+
* 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
|
|
29
|
+
* 4. user entries in config.toml (an override for `local`, or net-new envs)
|
|
30
|
+
* 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
|
|
30
31
|
*
|
|
31
32
|
* Registered OpCo routes are authoritative for their names so a lower-trust
|
|
32
33
|
* config.toml override cannot send a tenant token to another origin.
|
|
33
34
|
*/
|
|
34
35
|
export declare function readConfig(): Promise<LatticeConfig>;
|
|
36
|
+
export declare function localAuthRealmOptions(envName?: string): Promise<{
|
|
37
|
+
targetUrl?: string;
|
|
38
|
+
}>;
|
|
35
39
|
/** Write the config to disk, creating the dir if missing. */
|
|
36
40
|
export declare function writeConfig(config: LatticeConfig): Promise<void>;
|
|
37
41
|
export interface ResolvedEnv {
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
3
|
import { existsSync } from 'node:fs';
|
|
3
4
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
5
|
+
import { basename, dirname, join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
5
7
|
import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
|
|
6
8
|
import { M2mTokenError } from './auth.js';
|
|
7
9
|
import { fetchCatalog, readCachedCatalog, } from './env-catalog.js';
|
|
@@ -26,7 +28,69 @@ export const PREVIEW_ENV_PREFIX = 'preview:';
|
|
|
26
28
|
const BUILT_IN_ENV_URLS = {
|
|
27
29
|
local: 'http://localhost:5001',
|
|
28
30
|
};
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
const WORKTREE_ENV_PREFIX = 'local:';
|
|
33
|
+
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
29
34
|
export const ENV_NAMES = Object.keys(BUILT_IN_ENV_URLS);
|
|
35
|
+
async function atlasPortFromWorktreeEnv(directory) {
|
|
36
|
+
let text;
|
|
37
|
+
try {
|
|
38
|
+
text = await readFile(join(directory, '.wt.env'), 'utf8');
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
const value = text
|
|
44
|
+
.split('\n')
|
|
45
|
+
.map((line) => line.split('=', 2))
|
|
46
|
+
.find(([key]) => key?.trim() === 'ATLAS_PORT')?.[1]
|
|
47
|
+
?.trim();
|
|
48
|
+
if (!value || !/^\d+$/.test(value))
|
|
49
|
+
return undefined;
|
|
50
|
+
const port = Number(value);
|
|
51
|
+
return port >= 1 && port <= 65_535 ? port : undefined;
|
|
52
|
+
}
|
|
53
|
+
async function worktreeDirectories() {
|
|
54
|
+
const root = process.env.WT_ROOT ?? join(homedir(), 'studio-worktrees');
|
|
55
|
+
const directories = [];
|
|
56
|
+
try {
|
|
57
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
58
|
+
directories.push(...entries
|
|
59
|
+
.filter((entry) => entry.isDirectory())
|
|
60
|
+
.map((entry) => join(root, entry.name)));
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// The default worktree root is optional.
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
const { stdout } = await execFileAsync('git', ['worktree', 'list', '--porcelain']);
|
|
67
|
+
const gitWorktrees = stdout
|
|
68
|
+
.split('\n')
|
|
69
|
+
.filter((line) => line.startsWith('worktree '))
|
|
70
|
+
.map((line) => line.slice('worktree '.length))
|
|
71
|
+
.slice(1);
|
|
72
|
+
directories.push(...gitWorktrees);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Global CLI use outside a git checkout still supports WT_ROOT discovery.
|
|
76
|
+
}
|
|
77
|
+
return [...new Set(directories)];
|
|
78
|
+
}
|
|
79
|
+
async function worktreeEnvironmentUrls() {
|
|
80
|
+
const environments = {};
|
|
81
|
+
for (const directory of await worktreeDirectories()) {
|
|
82
|
+
const name = basename(directory);
|
|
83
|
+
if (!WORKTREE_NAME_PATTERN.test(name))
|
|
84
|
+
continue;
|
|
85
|
+
const port = await atlasPortFromWorktreeEnv(directory);
|
|
86
|
+
if (port === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
environments[`${WORKTREE_ENV_PREFIX}${name}`] ??= {
|
|
89
|
+
url: `http://localhost:${port}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
return environments;
|
|
93
|
+
}
|
|
30
94
|
export function globalConfigDir() {
|
|
31
95
|
return join(homedir(), '.config', 'lattice');
|
|
32
96
|
}
|
|
@@ -44,15 +108,17 @@ export function defaultConfig() {
|
|
|
44
108
|
* Read the effective config. Merge precedence (later wins):
|
|
45
109
|
*
|
|
46
110
|
* 1. built-in `local`
|
|
47
|
-
* 2.
|
|
48
|
-
* 3.
|
|
49
|
-
* 4.
|
|
111
|
+
* 2. local worktrees discovered from `.wt.env`
|
|
112
|
+
* 3. the cached discovered catalog (`~/.config/lattice/environments.json`)
|
|
113
|
+
* 4. user entries in config.toml (an override for `local`, or net-new envs)
|
|
114
|
+
* 5. OpCo registrations shared with seqapi (`~/.config/sequence-api/config.json`)
|
|
50
115
|
*
|
|
51
116
|
* Registered OpCo routes are authoritative for their names so a lower-trust
|
|
52
117
|
* config.toml override cannot send a tenant token to another origin.
|
|
53
118
|
*/
|
|
54
119
|
export async function readConfig() {
|
|
55
120
|
const merged = defaultConfig();
|
|
121
|
+
Object.assign(merged.envs, await worktreeEnvironmentUrls());
|
|
56
122
|
const catalog = await readCachedCatalog();
|
|
57
123
|
if (catalog) {
|
|
58
124
|
merged.tier = catalog.tier;
|
|
@@ -80,6 +146,12 @@ export async function readConfig() {
|
|
|
80
146
|
}
|
|
81
147
|
return merged;
|
|
82
148
|
}
|
|
149
|
+
export async function localAuthRealmOptions(envName) {
|
|
150
|
+
if (!envName || (envName !== 'local' && !envName.startsWith('local:'))) {
|
|
151
|
+
return {};
|
|
152
|
+
}
|
|
153
|
+
return { targetUrl: (await readConfig()).envs[envName]?.url };
|
|
154
|
+
}
|
|
83
155
|
/** Write the config to disk, creating the dir if missing. */
|
|
84
156
|
export async function writeConfig(config) {
|
|
85
157
|
const path = configPath();
|
package/dist/envs/commands.js
CHANGED
|
@@ -14,7 +14,8 @@ const ENVS_USAGE = `usage:
|
|
|
14
14
|
catalog is cached at ~/.config/lattice/environments.json and also refreshes
|
|
15
15
|
lazily the first time you pass an -e <env> that isn't cached yet.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
Local worktrees are discovered from their .wt.env files. Custom entries in
|
|
18
|
+
${configPath()} are always honored on top.
|
|
18
19
|
`;
|
|
19
20
|
export async function runEnvsCommand(sub, rest) {
|
|
20
21
|
switch (sub) {
|
|
@@ -97,11 +98,13 @@ async function listCommand() {
|
|
|
97
98
|
for (const [name, { url }] of Object.entries(config.envs)) {
|
|
98
99
|
const source = name === 'local' && !discovered.has(name)
|
|
99
100
|
? 'built-in'
|
|
100
|
-
:
|
|
101
|
-
? '
|
|
102
|
-
:
|
|
103
|
-
? '
|
|
104
|
-
:
|
|
101
|
+
: name.startsWith('local:')
|
|
102
|
+
? 'worktree'
|
|
103
|
+
: discovered.has(name)
|
|
104
|
+
? 'discovered'
|
|
105
|
+
: registeredNames.has(name)
|
|
106
|
+
? 'registered'
|
|
107
|
+
: 'config.toml';
|
|
105
108
|
console.log(` ${name.padEnd(width)} ${url} (${source})`);
|
|
106
109
|
}
|
|
107
110
|
if (config.tier === 'anonymous') {
|
package/dist/functions/bundle.js
CHANGED
|
@@ -59,13 +59,13 @@ export async function validateLocalBundle({ rootDir, files, }) {
|
|
|
59
59
|
if (existsSync(join(rootDir, 'package-lock.json')) || existsSync(join(rootDir, 'yarn.lock'))) {
|
|
60
60
|
issues.push({
|
|
61
61
|
level: 'warning',
|
|
62
|
-
message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored
|
|
62
|
+
message: 'Found a non-pnpm lockfile (package-lock.json / yarn.lock). It is ignored; dependencies are resolved server-side at deploy time.',
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
65
|
else {
|
|
66
66
|
issues.push({
|
|
67
67
|
level: 'info',
|
|
68
|
-
message: 'No pnpm-lock.yaml —
|
|
68
|
+
message: 'No pnpm-lock.yaml — dependencies are resolved server-side at deploy time.',
|
|
69
69
|
});
|
|
70
70
|
}
|
|
71
71
|
}
|
|
@@ -76,7 +76,7 @@ export async function validateLocalBundle({ rootDir, files, }) {
|
|
|
76
76
|
if (classifyLockfileOrigin(lockfileText) !== 'chainguard') {
|
|
77
77
|
issues.push({
|
|
78
78
|
level: 'warning',
|
|
79
|
-
message: 'pnpm-lock.yaml
|
|
79
|
+
message: 'pnpm-lock.yaml cannot be used for this deployment, so dependencies are resolved server-side at deploy time. Resolved versions may differ.',
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
}
|
|
@@ -202,6 +202,12 @@ limits:
|
|
|
202
202
|
max_instances: 3
|
|
203
203
|
invoke_rate_per_minute: 60
|
|
204
204
|
|
|
205
|
+
# Optional source-IP gate, checked before invocation permissions. Omit or leave
|
|
206
|
+
# empty for unrestricted source IPs.
|
|
207
|
+
# invocation:
|
|
208
|
+
# allowed_ip_ranges:
|
|
209
|
+
# - 203.0.113.0/24
|
|
210
|
+
|
|
205
211
|
# Env-var names the function expects (UPPER_SNAKE_CASE). Values are read
|
|
206
212
|
# from a local .env file at deploy time — they are never committed or bundled.
|
|
207
213
|
secrets: []
|
|
@@ -325,10 +331,6 @@ export async function functionsInitCommand(args) {
|
|
|
325
331
|
console.log('');
|
|
326
332
|
console.log('Next steps (init is only needed when creating a function from scratch):');
|
|
327
333
|
console.log(` cd ${target}`);
|
|
328
|
-
console.log(' pnpm install # installs deps for local dev + pins versions via Chainguard.');
|
|
329
|
-
console.log(' # Requires Chainguard credentials (Sequence-internal). Without');
|
|
330
|
-
console.log(' # them this 401s — delete the scaffolded .npmrc and install from');
|
|
331
|
-
console.log(' # public npm instead (the deploy worker re-resolves server-side).');
|
|
332
334
|
console.log(' seq-studio functions build # local pre-flight checks');
|
|
333
335
|
console.log(' # Add secret names to managed-function.yml (secrets: [MY_SECRET]) and values to .env');
|
|
334
336
|
console.log(' seq-studio functions deploy -e <env> # upload, apply secrets, then deploy');
|
|
@@ -387,6 +389,15 @@ async function buildFromResolvedSource({ spec, source, }) {
|
|
|
387
389
|
// ---------------------------------------------------------------------------
|
|
388
390
|
// deploy
|
|
389
391
|
// ---------------------------------------------------------------------------
|
|
392
|
+
function buildInvocationIpPreviewLines({ ranges }) {
|
|
393
|
+
if (ranges.length === 0) {
|
|
394
|
+
return [`${LOG} invocation IPs: unrestricted`];
|
|
395
|
+
}
|
|
396
|
+
return [
|
|
397
|
+
`${LOG} invocation IPs:`,
|
|
398
|
+
...ranges.map((range) => `${LOG} ${range}`),
|
|
399
|
+
];
|
|
400
|
+
}
|
|
390
401
|
async function getFunctionDetail(ctx, functionId) {
|
|
391
402
|
try {
|
|
392
403
|
return await getJson({
|
|
@@ -561,7 +572,9 @@ async function deployFromResolvedSource({ args, spec, ctx: earlyCtx, source, })
|
|
|
561
572
|
preview.push(...buildEgressPreviewLines(LOG, [
|
|
562
573
|
...manifestEgressHosts(manifest.egress),
|
|
563
574
|
...manifestEgressIpRanges(manifest.egress),
|
|
564
|
-
])
|
|
575
|
+
]), ...buildInvocationIpPreviewLines({
|
|
576
|
+
ranges: manifest.invocation.allowed_ip_ranges,
|
|
577
|
+
}));
|
|
565
578
|
if (undeclaredEnvKeys.length > 0) {
|
|
566
579
|
preview.push(`${LOG} note: .env has ${undeclaredEnvKeys.join(', ')} — not declared in manifest, ignored`);
|
|
567
580
|
}
|
|
@@ -70,6 +70,9 @@ export declare const managedFunctionManifestSchema: z.ZodObject<{
|
|
|
70
70
|
max_instances: z.ZodDefault<z.ZodNumber>;
|
|
71
71
|
invoke_rate_per_minute: z.ZodDefault<z.ZodNumber>;
|
|
72
72
|
}, z.core.$strip>>;
|
|
73
|
+
invocation: z.ZodDefault<z.ZodObject<{
|
|
74
|
+
allowed_ip_ranges: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
75
|
+
}, z.core.$strip>>;
|
|
73
76
|
secrets: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
74
77
|
egress: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
75
78
|
service_account: z.ZodOptional<z.ZodString>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isIP } from 'node:net';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
/**
|
|
3
4
|
* CLI-side mirror of the server manifest schema at
|
|
@@ -515,6 +516,43 @@ const SERVICE_ACCOUNT_REF_RE = /^([a-z][a-z0-9-]{1,98}|[0-9a-fA-F]{8}-[0-9a-fA-F
|
|
|
515
516
|
* atlas/src/server/services/managed-functions/manifest.ts.
|
|
516
517
|
*/
|
|
517
518
|
const GRAPHQL_OPERATION_NAME_RE = /^[_A-Za-z][_0-9A-Za-z]*$/;
|
|
519
|
+
/**
|
|
520
|
+
* Fast local validation for the declarative invocation policy. Atlas remains
|
|
521
|
+
* authoritative and canonicalizes network addresses during version upload;
|
|
522
|
+
* this mirror prevents a malformed bundle from reaching the network.
|
|
523
|
+
*/
|
|
524
|
+
const invocationIpRangeSchema = z
|
|
525
|
+
.string()
|
|
526
|
+
.trim()
|
|
527
|
+
.min(1)
|
|
528
|
+
.max(128)
|
|
529
|
+
.superRefine((value, ctx) => {
|
|
530
|
+
const parts = value.split('/');
|
|
531
|
+
if (parts.length > 2) {
|
|
532
|
+
ctx.addIssue({ code: 'custom', message: `invalid IP address or CIDR range: ${value}` });
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const [address, prefixText] = parts;
|
|
536
|
+
const family = isIP(address ?? '');
|
|
537
|
+
if (family === 0) {
|
|
538
|
+
ctx.addIssue({ code: 'custom', message: `invalid IP address or CIDR range: ${value}` });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (prefixText === undefined)
|
|
542
|
+
return;
|
|
543
|
+
if (!/^\d+$/.test(prefixText)) {
|
|
544
|
+
ctx.addIssue({ code: 'custom', message: `invalid CIDR prefix: ${value}` });
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
const prefix = Number(prefixText);
|
|
548
|
+
const maxPrefix = family === 4 ? 32 : 128;
|
|
549
|
+
if (prefix < 1 || prefix > maxPrefix) {
|
|
550
|
+
ctx.addIssue({
|
|
551
|
+
code: 'custom',
|
|
552
|
+
message: `CIDR prefix must be between 1 and ${maxPrefix}: ${value}`,
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
});
|
|
518
556
|
export const managedFunctionManifestSchema = z.object({
|
|
519
557
|
schema_version: z.literal(1).default(1),
|
|
520
558
|
function: z.object({
|
|
@@ -552,6 +590,16 @@ export const managedFunctionManifestSchema = z.object({
|
|
|
552
590
|
max_instances: 3,
|
|
553
591
|
invoke_rate_per_minute: 60,
|
|
554
592
|
}),
|
|
593
|
+
/**
|
|
594
|
+
* Source-IP gate applied by the Atlas invocation gateway before FGA.
|
|
595
|
+
* Empty or omitted means unrestricted. The policy is versioned with the
|
|
596
|
+
* bundle and becomes live atomically with that version.
|
|
597
|
+
*/
|
|
598
|
+
invocation: z
|
|
599
|
+
.object({
|
|
600
|
+
allowed_ip_ranges: z.array(invocationIpRangeSchema).max(64).default([]),
|
|
601
|
+
})
|
|
602
|
+
.default({ allowed_ip_ranges: [] }),
|
|
555
603
|
secrets: z
|
|
556
604
|
.array(z.string().regex(SECRET_NAME_RE, 'secret names must be UPPER_SNAKE_CASE'))
|
|
557
605
|
.max(32)
|
package/dist/login.js
CHANGED
|
@@ -5,7 +5,7 @@ import { spawn } from 'node:child_process';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { deleteRealmTokens, realmForEnv, saveTokens, seqapiTokenPath, SEQUENCE_AUTH_REALM, SEQUENCE_REALM, verifyTokenMatchesRealm, } from './auth.js';
|
|
8
|
-
import { manualEnvConfigHint } from './config.js';
|
|
8
|
+
import { localAuthRealmOptions, manualEnvConfigHint } from './config.js';
|
|
9
9
|
import { bootstrapUrl, clearCatalog, fetchCatalog } from './env-catalog.js';
|
|
10
10
|
const DEFAULT_REDIRECT_PORT = 5099;
|
|
11
11
|
const DEFAULT_LOGIN_TIMEOUT_MS = 300_000;
|
|
@@ -216,7 +216,7 @@ export async function refreshCatalogAfterLogin() {
|
|
|
216
216
|
console.log(manualEnvConfigHint());
|
|
217
217
|
}
|
|
218
218
|
export async function login(envName) {
|
|
219
|
-
const realm = await realmForEnv(envName);
|
|
219
|
+
const realm = await realmForEnv(envName, await localAuthRealmOptions(envName));
|
|
220
220
|
if (envName && realm.name === SEQUENCE_REALM && envName !== SEQUENCE_REALM) {
|
|
221
221
|
// A name that only exists in the Sequence catalog (staging, banksouth…)
|
|
222
222
|
// resolves to the Sequence realm — one login covers all of those. A typo'd
|
|
@@ -241,7 +241,7 @@ function legacyArtifactTokenPath() {
|
|
|
241
241
|
return join(homedir(), '.config', 'sequence-artifact-studio', 'tokens.json');
|
|
242
242
|
}
|
|
243
243
|
export async function logout(envName) {
|
|
244
|
-
const realm = await realmForEnv(envName);
|
|
244
|
+
const realm = await realmForEnv(envName, await localAuthRealmOptions(envName));
|
|
245
245
|
await deleteRealmTokens(realm.name);
|
|
246
246
|
if (realm.name === SEQUENCE_REALM) {
|
|
247
247
|
await rm(legacyArtifactTokenPath(), { force: true });
|
|
@@ -38,13 +38,17 @@ const PIPELINE_USAGE = `usage:
|
|
|
38
38
|
module (default export, authoring, or silverlakeAuthoring export name)
|
|
39
39
|
|
|
40
40
|
seq-studio pipeline plan --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--json]
|
|
41
|
+
enqueue a durable plan, poll progress, then print the completed
|
|
41
42
|
materialize + SDK/graph + compile + live-diff + provision findings
|
|
42
43
|
(no Databricks CLI / DAB validate on Atlas); exit 1 on destructive findings
|
|
43
44
|
|
|
44
45
|
seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env>
|
|
45
|
-
[--target <id>] [--approved-by <sub>] [--no-wait] [--json]
|
|
46
|
+
[--target <id>] [--approved-by <sub>] [--run-now] [--no-wait] [--json]
|
|
46
47
|
plan then enqueue Trigger deploy (DAB bundle validate hard-gates before
|
|
47
|
-
bundle deploy)
|
|
48
|
+
bundle deploy). Default is plant-only: jobs/pipelines are created, not
|
|
49
|
+
run. Pass --run-now to run in-unit producer roots after bundle deploy
|
|
50
|
+
and wait before serving sync create. Use a full 40-hex SHA for explicit
|
|
51
|
+
rollback deployments
|
|
48
52
|
|
|
49
53
|
seq-studio pipeline promote --stage <slug> --version <v> -e <env>
|
|
50
54
|
[--target <id>] [--repo pipelines/<slug>] [--approved-by <you>] [--no-wait]
|
|
@@ -9,6 +9,7 @@ const LOG = '[seq-studio]';
|
|
|
9
9
|
const TERMINAL = new Set(['active', 'failed', 'retired']);
|
|
10
10
|
const POLL_INTERVAL_MS = 2000;
|
|
11
11
|
const HEARTBEAT_INTERVAL_MS = 20_000;
|
|
12
|
+
const PLAN_TIMEOUT_MS = 30 * 60 * 1000;
|
|
12
13
|
const FIRST_PARTY_TARGETS = [
|
|
13
14
|
{ id: 'dev', label: 'Development', requiresApproval: false },
|
|
14
15
|
{ id: 'staging', label: 'Staging', requiresApproval: false },
|
|
@@ -56,6 +57,9 @@ function flagString(flags, key) {
|
|
|
56
57
|
throw new Error(`--${key} requires a value`);
|
|
57
58
|
return value;
|
|
58
59
|
}
|
|
60
|
+
function flagOn(flags, key) {
|
|
61
|
+
return flags[key] === true || flags[key] === 'true';
|
|
62
|
+
}
|
|
59
63
|
export function deployEnvironmentForEnv(env) {
|
|
60
64
|
return env.name === 'local' ? 'dev' : env.name;
|
|
61
65
|
}
|
|
@@ -101,19 +105,17 @@ export async function pipelinePlanCommand(args) {
|
|
|
101
105
|
}
|
|
102
106
|
let response;
|
|
103
107
|
try {
|
|
104
|
-
response = await
|
|
108
|
+
response = await enqueueAndWaitForPlan({
|
|
105
109
|
baseUrl: env.url,
|
|
106
110
|
token,
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
repo,
|
|
112
|
+
ref,
|
|
113
|
+
environment: target.id,
|
|
109
114
|
});
|
|
110
115
|
}
|
|
111
116
|
catch (error) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
return 1;
|
|
115
|
-
}
|
|
116
|
-
throw error;
|
|
117
|
+
console.error(`${LOG} plan failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
118
|
+
return 1;
|
|
117
119
|
}
|
|
118
120
|
if (json) {
|
|
119
121
|
console.log(JSON.stringify(response, null, 2));
|
|
@@ -129,6 +131,7 @@ export async function pipelineDeployCommand(args) {
|
|
|
129
131
|
const ref = flagString(args.flags, 'ref');
|
|
130
132
|
const deploymentId = flagString(args.flags, 'deployment-id');
|
|
131
133
|
const approvedBy = flagString(args.flags, 'approved-by');
|
|
134
|
+
const runNow = flagOn(args.flags, 'run-now');
|
|
132
135
|
const { env, token } = await envAndToken(args);
|
|
133
136
|
let target;
|
|
134
137
|
try {
|
|
@@ -144,27 +147,33 @@ export async function pipelineDeployCommand(args) {
|
|
|
144
147
|
// Direct execute of a persisted plan.
|
|
145
148
|
const stageId = flagString(args.flags, 'stage-id');
|
|
146
149
|
if (!stageId) {
|
|
147
|
-
console.error('usage: seq-studio pipeline deploy --deployment-id <id> --stage-id <id> -e <env> [--target <id>] [--approved-by <sub>] [--no-wait]');
|
|
150
|
+
console.error('usage: seq-studio pipeline deploy --deployment-id <id> --stage-id <id> -e <env> [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait]');
|
|
148
151
|
return 1;
|
|
149
152
|
}
|
|
150
153
|
enqueue = await postJson({
|
|
151
154
|
baseUrl: env.url,
|
|
152
155
|
token,
|
|
153
156
|
path: `/api/data-pipelines/stages/${stageId}/deploy`,
|
|
154
|
-
body: {
|
|
157
|
+
body: {
|
|
158
|
+
deploymentId,
|
|
159
|
+
environment: target.id,
|
|
160
|
+
...(approvedBy ? { approvedBy } : {}),
|
|
161
|
+
runNow,
|
|
162
|
+
},
|
|
155
163
|
});
|
|
156
164
|
}
|
|
157
165
|
else {
|
|
158
166
|
if (!repo || !ref) {
|
|
159
|
-
console.error('usage: seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--approved-by <sub>] [--no-wait]');
|
|
167
|
+
console.error('usage: seq-studio pipeline deploy --repo pipelines/<slug> --ref <sha|branch> -e <env> [--target <id>] [--approved-by <sub>] [--run-now] [--no-wait]');
|
|
160
168
|
return 1;
|
|
161
169
|
}
|
|
162
170
|
// Plan then deploy the first planning deployment.
|
|
163
|
-
const plan = await
|
|
171
|
+
const plan = await enqueueAndWaitForPlan({
|
|
164
172
|
baseUrl: env.url,
|
|
165
173
|
token,
|
|
166
|
-
|
|
167
|
-
|
|
174
|
+
repo,
|
|
175
|
+
ref,
|
|
176
|
+
environment: target.id,
|
|
168
177
|
});
|
|
169
178
|
if (plan.plan.hasDestructive) {
|
|
170
179
|
console.error(`${LOG} plan has destructive findings — refusing to deploy`);
|
|
@@ -190,6 +199,7 @@ export async function pipelineDeployCommand(args) {
|
|
|
190
199
|
deploymentId: firstId,
|
|
191
200
|
environment: target.id,
|
|
192
201
|
...(approvedBy ? { approvedBy } : {}),
|
|
202
|
+
runNow,
|
|
193
203
|
},
|
|
194
204
|
});
|
|
195
205
|
}
|
|
@@ -250,10 +260,20 @@ export async function pipelinePromoteCommand(args) {
|
|
|
250
260
|
}
|
|
251
261
|
throw error;
|
|
252
262
|
}
|
|
253
|
-
console.log(`${LOG} enqueued promote ${enqueue.
|
|
263
|
+
console.log(`${LOG} enqueued promote plan ${enqueue.planRequestId} (trigger=${enqueue.triggerRunId ?? 'n/a'})`);
|
|
254
264
|
if (args.flags['no-wait'] === true || args.flags['no-wait'] === 'true')
|
|
255
265
|
return 0;
|
|
256
|
-
|
|
266
|
+
const plan = await waitForPlanRequest({
|
|
267
|
+
baseUrl: env.url,
|
|
268
|
+
token,
|
|
269
|
+
planRequestId: enqueue.planRequestId,
|
|
270
|
+
});
|
|
271
|
+
const deploymentId = plan.deploymentIds[plan.stageIds.indexOf(stageId)] ?? plan.deploymentIds[0];
|
|
272
|
+
if (!deploymentId) {
|
|
273
|
+
console.error(`${LOG} promote plan completed without a deployment id`);
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
return pollDeployment({ baseUrl: env.url, token, deploymentId });
|
|
257
277
|
}
|
|
258
278
|
export async function pipelineRunNowCommand(args) {
|
|
259
279
|
const stage = flagString(args.flags, 'stage');
|
|
@@ -404,6 +424,63 @@ async function pollDeployment({ baseUrl, token, deploymentId, }) {
|
|
|
404
424
|
console.error(`${LOG} timed out waiting for deployment ${deploymentId} (${formatElapsed(Date.now() - started)})`);
|
|
405
425
|
return 1;
|
|
406
426
|
}
|
|
427
|
+
async function enqueueAndWaitForPlan({ baseUrl, token, repo, ref, environment, }) {
|
|
428
|
+
const enqueue = await postJson({
|
|
429
|
+
baseUrl,
|
|
430
|
+
token,
|
|
431
|
+
path: '/api/data-pipelines/pipelines/plan',
|
|
432
|
+
body: { repo, ref, environment },
|
|
433
|
+
});
|
|
434
|
+
console.log(`${LOG} enqueued plan ${enqueue.planRequestId} (trigger=${enqueue.triggerRunId ?? 'n/a'})`);
|
|
435
|
+
const detail = await waitForPlanRequest({
|
|
436
|
+
baseUrl,
|
|
437
|
+
token,
|
|
438
|
+
planRequestId: enqueue.planRequestId,
|
|
439
|
+
});
|
|
440
|
+
if (!detail.plan || !detail.text) {
|
|
441
|
+
throw new Error(`plan request ${detail.planRequestId} succeeded without a plan result`);
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
plan: detail.plan,
|
|
445
|
+
deploymentIds: detail.deploymentIds,
|
|
446
|
+
stageIds: detail.stageIds,
|
|
447
|
+
text: detail.text,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
async function waitForPlanRequest({ baseUrl, token, planRequestId, }) {
|
|
451
|
+
const started = Date.now();
|
|
452
|
+
let lastStatus = null;
|
|
453
|
+
let lastLoggedAt = started;
|
|
454
|
+
while (Date.now() - started < PLAN_TIMEOUT_MS) {
|
|
455
|
+
const detail = await getJson({
|
|
456
|
+
baseUrl,
|
|
457
|
+
token,
|
|
458
|
+
path: `/api/data-pipelines/plan-requests/${planRequestId}`,
|
|
459
|
+
});
|
|
460
|
+
const elapsed = Date.now() - started;
|
|
461
|
+
const statusLabel = detail.statusDetail
|
|
462
|
+
? `${detail.status} — ${detail.statusDetail}`
|
|
463
|
+
: detail.status;
|
|
464
|
+
if (detail.status === 'failed') {
|
|
465
|
+
throw new Error(`plan request ${detail.planRequestId} failed after ${formatElapsed(elapsed)}: ` +
|
|
466
|
+
`${detail.statusDetail ?? 'unknown error'}`);
|
|
467
|
+
}
|
|
468
|
+
if (detail.status === 'succeeded')
|
|
469
|
+
return detail;
|
|
470
|
+
if (statusLabel !== lastStatus) {
|
|
471
|
+
console.log(`${LOG} plan ${detail.planRequestId}: ${statusLabel} (${formatElapsed(elapsed)})`);
|
|
472
|
+
lastStatus = statusLabel;
|
|
473
|
+
lastLoggedAt = Date.now();
|
|
474
|
+
}
|
|
475
|
+
else if (Date.now() - lastLoggedAt >= HEARTBEAT_INTERVAL_MS) {
|
|
476
|
+
console.log(`${LOG} plan ${detail.planRequestId}: still ${statusLabel} (${formatElapsed(elapsed)})`);
|
|
477
|
+
lastLoggedAt = Date.now();
|
|
478
|
+
}
|
|
479
|
+
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
480
|
+
}
|
|
481
|
+
throw new Error(`timed out waiting for plan request ${planRequestId} ` +
|
|
482
|
+
`(${formatElapsed(Date.now() - started)})`);
|
|
483
|
+
}
|
|
407
484
|
export async function pipelineAdoptCommand(args) {
|
|
408
485
|
const stage = flagString(args.flags, 'stage');
|
|
409
486
|
const ref = flagString(args.flags, 'ref');
|
package/dist/preview.d.ts
CHANGED
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* hand-editing `~/.config/lattice/config.toml`. See
|
|
7
7
|
* `docs/preview-environments.md` for the full lifecycle.
|
|
8
8
|
*
|
|
9
|
-
* SLUG ALGORITHM — must stay byte-for-byte in sync with the
|
|
9
|
+
* SLUG ALGORITHM — must stay byte-for-byte in sync with the other
|
|
10
10
|
* places that compute the same slug from a branch name:
|
|
11
|
-
* - `.github/workflows/preview-deploy.yml` (
|
|
12
|
-
* -
|
|
11
|
+
* - `.github/workflows/preview-deploy.yml` (replace non-alnum, lowercase, trim hyphens)
|
|
12
|
+
* - `.github/workflows/trigger-preview.yml` (same bash pipeline)
|
|
13
|
+
* - `atlas/src/server/db.ts:sanitize` (`.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '')`)
|
|
13
14
|
* - `databricks/lakebase/atlas-db/pr-lifecycle.ts:sanitize`
|
|
14
15
|
* Changing it here without changing those produces a host that does NOT
|
|
15
16
|
* match what `preview-deploy.yml` actually deployed.
|
package/dist/preview.js
CHANGED
|
@@ -8,10 +8,11 @@ import { promisify } from 'node:util';
|
|
|
8
8
|
* hand-editing `~/.config/lattice/config.toml`. See
|
|
9
9
|
* `docs/preview-environments.md` for the full lifecycle.
|
|
10
10
|
*
|
|
11
|
-
* SLUG ALGORITHM — must stay byte-for-byte in sync with the
|
|
11
|
+
* SLUG ALGORITHM — must stay byte-for-byte in sync with the other
|
|
12
12
|
* places that compute the same slug from a branch name:
|
|
13
|
-
* - `.github/workflows/preview-deploy.yml` (
|
|
14
|
-
* -
|
|
13
|
+
* - `.github/workflows/preview-deploy.yml` (replace non-alnum, lowercase, trim hyphens)
|
|
14
|
+
* - `.github/workflows/trigger-preview.yml` (same bash pipeline)
|
|
15
|
+
* - `atlas/src/server/db.ts:sanitize` (`.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '')`)
|
|
15
16
|
* - `databricks/lakebase/atlas-db/pr-lifecycle.ts:sanitize`
|
|
16
17
|
* Changing it here without changing those produces a host that does NOT
|
|
17
18
|
* match what `preview-deploy.yml` actually deployed.
|
|
@@ -45,7 +46,7 @@ export const PREVIEW_PROTECTED_SLUGS = [
|
|
|
45
46
|
export const MAX_LABEL_LENGTH = 63;
|
|
46
47
|
/** Apply the canonical branch → slug transform. */
|
|
47
48
|
export function previewSlug(branchOrSlug) {
|
|
48
|
-
return branchOrSlug.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase();
|
|
49
|
+
return branchOrSlug.replace(/[^a-zA-Z0-9-]/g, '-').toLowerCase().replace(/^-+|-+$/g, '');
|
|
49
50
|
}
|
|
50
51
|
/** The `studio-atlas-git-<slug>` DNS label Vercel assigns the preview. */
|
|
51
52
|
export function previewLabel(slug) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sequenceholdings/studio-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.25",
|
|
4
4
|
"description": "Unified Sequence Studio CLI — `seq-studio init` / `add` / `deploy` (app monorepos), `seq-studio agents`, `seq-studio process` (Lattice), `seq-studio artifact`, `seq-studio functions` / `secrets`, `seq-studio repos`, and `seq-studio auth pat`. Includes Auth0 browser login shared with seqapi.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -36,13 +36,13 @@
|
|
|
36
36
|
"README.md"
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"js-yaml": "^4.
|
|
39
|
+
"js-yaml": "^4.3.1",
|
|
40
40
|
"smol-toml": "^1.4.2",
|
|
41
41
|
"tsx": "^4.20.3",
|
|
42
42
|
"zod": "^4.1.13",
|
|
43
|
-
"@sequenceholdings/
|
|
44
|
-
"@sequenceholdings/
|
|
45
|
-
"@sequenceholdings/
|
|
43
|
+
"@sequenceholdings/agent-spec": "0.1.1",
|
|
44
|
+
"@sequenceholdings/artifact-studio": "0.2.2",
|
|
45
|
+
"@sequenceholdings/lattice": "0.1.2"
|
|
46
46
|
},
|
|
47
47
|
"peerDependencies": {
|
|
48
48
|
"@sequenceholdings/orm": "0.1.3",
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"@types/node": "^22.0.0",
|
|
62
62
|
"typescript": "^5.6.0",
|
|
63
63
|
"vitest": "^4.1.5",
|
|
64
|
-
"@sequenceholdings/
|
|
65
|
-
"@sequenceholdings/
|
|
64
|
+
"@sequenceholdings/orm": "0.1.3",
|
|
65
|
+
"@sequenceholdings/pipeline-spec": "0.1.0"
|
|
66
66
|
},
|
|
67
67
|
"engines": {
|
|
68
68
|
"node": ">=20"
|