@xano/cli 1.3.3 → 1.3.5-beta.0
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 +137 -3
- package/dist/base-command.d.ts +12 -1
- package/dist/base-command.js +36 -22
- package/dist/commands/branch/set_live/index.d.ts +14 -0
- package/dist/commands/branch/set_live/index.js +38 -0
- package/dist/commands/ephemeral/push/index.js +6 -1
- package/dist/commands/function/create/index.d.ts +15 -0
- package/dist/commands/function/create/index.js +39 -0
- package/dist/commands/function/edit/index.d.ts +15 -0
- package/dist/commands/function/edit/index.js +39 -0
- package/dist/commands/function/run/index.js +5 -11
- package/dist/commands/policy/catalogue/index.d.ts +16 -0
- package/dist/commands/policy/catalogue/index.js +60 -0
- package/dist/commands/policy/coverage/index.d.ts +17 -0
- package/dist/commands/policy/coverage/index.js +33 -0
- package/dist/commands/policy/create/index.d.ts +18 -0
- package/dist/commands/policy/create/index.js +44 -0
- package/dist/commands/policy/delete/index.d.ts +19 -0
- package/dist/commands/policy/delete/index.js +61 -0
- package/dist/commands/policy/evaluate/index.d.ts +32 -0
- package/dist/commands/policy/evaluate/index.js +71 -0
- package/dist/commands/policy/list/index.d.ts +14 -0
- package/dist/commands/policy/list/index.js +21 -0
- package/dist/commands/policy/parse/index.d.ts +19 -0
- package/dist/commands/policy/parse/index.js +18 -0
- package/dist/commands/policy/publish/index.d.ts +20 -0
- package/dist/commands/policy/publish/index.js +38 -0
- package/dist/commands/policy/runs/index.d.ts +54 -0
- package/dist/commands/policy/runs/index.js +273 -0
- package/dist/commands/policy/status/index.d.ts +21 -0
- package/dist/commands/policy/status/index.js +54 -0
- package/dist/commands/release/create/index.js +4 -0
- package/dist/commands/release/deploy/index.d.ts +19 -0
- package/dist/commands/release/deploy/index.js +68 -6
- package/dist/commands/release/import/index.js +5 -1
- package/dist/commands/release/list/index.d.ts +7 -0
- package/dist/commands/release/list/index.js +46 -9
- package/dist/commands/release/push/index.js +19 -1
- package/dist/commands/sandbox/delete/index.js +8 -0
- package/dist/commands/sandbox/pull/index.d.ts +7 -0
- package/dist/commands/sandbox/pull/index.js +49 -0
- package/dist/commands/sandbox/push/index.d.ts +4 -0
- package/dist/commands/sandbox/push/index.js +59 -2
- package/dist/commands/sandbox/reset/index.js +8 -0
- package/dist/commands/skills/pull/index.d.ts +30 -0
- package/dist/commands/skills/pull/index.js +118 -0
- package/dist/commands/tenant/create/index.d.ts +3 -0
- package/dist/commands/tenant/create/index.js +21 -0
- package/dist/commands/tenant/deploy_release/index.d.ts +40 -0
- package/dist/commands/tenant/deploy_release/index.js +182 -4
- package/dist/commands/tenant/edit/index.d.ts +2 -0
- package/dist/commands/tenant/edit/index.js +39 -4
- package/dist/commands/tenant_deploy_request/bypass/index.d.ts +31 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +109 -0
- package/dist/commands/tenant_deploy_request/create/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/create/index.js +108 -0
- package/dist/commands/tenant_deploy_request/edit/index.d.ts +22 -0
- package/dist/commands/tenant_deploy_request/edit/index.js +103 -0
- package/dist/commands/tenant_deploy_request/get/index.d.ts +25 -0
- package/dist/commands/tenant_deploy_request/get/index.js +113 -0
- package/dist/commands/tenant_deploy_request/list/index.d.ts +20 -0
- package/dist/commands/tenant_deploy_request/list/index.js +122 -0
- package/dist/commands/tenant_deploy_request/revision/index.d.ts +21 -0
- package/dist/commands/tenant_deploy_request/revision/index.js +83 -0
- package/dist/commands/tenant_deploy_request/set_status/index.d.ts +38 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +159 -0
- package/dist/commands/workspace/pull/index.d.ts +17 -0
- package/dist/commands/workspace/pull/index.js +124 -7
- package/dist/commands/workspace/push/index.d.ts +23 -0
- package/dist/commands/workspace/push/index.js +188 -6
- package/dist/policy-command.d.ts +91 -0
- package/dist/policy-command.js +95 -0
- package/dist/utils/document-parser.d.ts +10 -0
- package/dist/utils/document-parser.js +21 -0
- package/dist/utils/export-compare.d.ts +52 -0
- package/dist/utils/export-compare.js +134 -0
- package/dist/utils/local-config.d.ts +23 -0
- package/dist/utils/local-config.js +62 -2
- package/dist/utils/multidoc-push.d.ts +124 -4
- package/dist/utils/multidoc-push.js +670 -49
- package/dist/utils/object-fetch.d.ts +86 -0
- package/dist/utils/object-fetch.js +265 -0
- package/dist/utils/policy/catalogue.d.ts +85 -0
- package/dist/utils/policy/catalogue.js +196 -0
- package/dist/utils/policy/coverage.d.ts +19 -0
- package/dist/utils/policy/coverage.js +93 -0
- package/dist/utils/policy/errors.d.ts +20 -0
- package/dist/utils/policy/errors.js +93 -0
- package/dist/utils/policy/feedback.d.ts +90 -0
- package/dist/utils/policy/feedback.js +210 -0
- package/dist/utils/policy/findings.d.ts +29 -0
- package/dist/utils/policy/findings.js +82 -0
- package/dist/utils/policy/gate.d.ts +138 -0
- package/dist/utils/policy/gate.js +193 -0
- package/dist/utils/policy/permission.d.ts +7 -0
- package/dist/utils/policy/permission.js +55 -0
- package/dist/utils/policy/push-gate.d.ts +5 -0
- package/dist/utils/policy/push-gate.js +43 -0
- package/dist/utils/policy/release.d.ts +85 -0
- package/dist/utils/policy/release.js +117 -0
- package/dist/utils/policy/request.d.ts +55 -0
- package/dist/utils/policy/request.js +119 -0
- package/dist/utils/policy/runs.d.ts +30 -0
- package/dist/utils/policy/runs.js +101 -0
- package/dist/utils/policy/status.d.ts +55 -0
- package/dist/utils/policy/status.js +127 -0
- package/dist/utils/policy/template.d.ts +66 -0
- package/dist/utils/policy/template.js +152 -0
- package/dist/utils/policy/types.d.ts +295 -0
- package/dist/utils/policy/types.js +1 -0
- package/dist/utils/reference-checker.d.ts +3 -0
- package/dist/utils/reference-checker.js +3 -2
- package/dist/utils/sandbox-marker.d.ts +9 -0
- package/dist/utils/sandbox-marker.js +119 -0
- package/dist/utils/sync-state.d.ts +71 -0
- package/dist/utils/sync-state.js +353 -0
- package/oclif.manifest.json +6472 -3181
- package/package.json +9 -1
|
@@ -6,12 +6,52 @@ export default class TenantDeployRelease extends BaseCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
check: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
|
+
'override-reason': import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
12
|
release: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
13
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
14
|
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
15
|
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
16
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
17
|
};
|
|
18
|
+
/** Set once the policy gate refuses the deploy, the one failure that exits 2. */
|
|
19
|
+
private refusedByPolicyGate;
|
|
20
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
21
|
+
protected catch(error: Error & {
|
|
22
|
+
oclif?: {
|
|
23
|
+
exit?: number;
|
|
24
|
+
};
|
|
25
|
+
}): Promise<void>;
|
|
16
26
|
run(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* `--check`: what the policy gate would decide for this release on this tenant, without deploying.
|
|
29
|
+
* Exits 2 when the deploy would be refused; a preview the platform cannot answer exits 1.
|
|
30
|
+
*/
|
|
31
|
+
private checkPolicyGate;
|
|
32
|
+
/**
|
|
33
|
+
* Does an approved tenant_deploy_request already exist for this (tenant, release)
|
|
34
|
+
* pair? The list endpoint has no release filter, so this fetches approved
|
|
35
|
+
* requests for the tenant and filters client-side by the resolved release name.
|
|
36
|
+
*/
|
|
37
|
+
private hasApprovedDeployRequest;
|
|
38
|
+
/** A deploy the policy gate refused: its verdict, then the refusal and how to proceed, exiting 2. */
|
|
39
|
+
private refuseByPolicyGate;
|
|
40
|
+
/**
|
|
41
|
+
* Does this tenant have the deploy approval gate enabled?
|
|
42
|
+
*
|
|
43
|
+
* Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
|
|
44
|
+
* tenant skip the approval gate entirely for quick deploys, regardless of
|
|
45
|
+
* `required_reviewers`.
|
|
46
|
+
*
|
|
47
|
+
* Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
|
|
48
|
+
* GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
|
|
49
|
+
* (which holds `required_reviewers`, `allow_deploy_bypass`, and
|
|
50
|
+
* `allow_quick_deploy`) even though the list route's output includes it
|
|
51
|
+
* — confirmed live against a running instance. Filed as a backend bug; this
|
|
52
|
+
* is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
|
|
53
|
+
*/
|
|
54
|
+
private tenantRequiresApproval;
|
|
55
|
+
/** A release policy the tenant's import could not land (its key is held by another policy) is never dropped silently. */
|
|
56
|
+
private warnPoliciesSkipped;
|
|
17
57
|
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Args, Flags } from '@oclif/core';
|
|
2
2
|
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { deployedGateLine, fetchPolicyGate, gateExitCode, gateLines, gateOverrideHint, gateRefusal, gateRefusalOutput, quoted, } from '../../../utils/policy/gate.js';
|
|
4
|
+
import { policyPermissionGuidance } from '../../../utils/policy/permission.js';
|
|
3
5
|
export default class TenantDeployRelease extends BaseCommand {
|
|
4
6
|
static args = {
|
|
5
7
|
tenant_name: Args.string({
|
|
@@ -7,7 +9,7 @@ export default class TenantDeployRelease extends BaseCommand {
|
|
|
7
9
|
required: true,
|
|
8
10
|
}),
|
|
9
11
|
};
|
|
10
|
-
static description =
|
|
12
|
+
static description = "[CRITICAL] STOP and confirm with the user before deploying a release to a tenant; this mutates the live tenant. Deploys a release to a tenant.";
|
|
11
13
|
static examples = [
|
|
12
14
|
`$ xano tenant deploy_release t1234-abcd-xyz1 --release v1.0
|
|
13
15
|
Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
@@ -16,6 +18,12 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
16
18
|
];
|
|
17
19
|
static flags = {
|
|
18
20
|
...BaseCommand.baseFlags,
|
|
21
|
+
check: Flags.boolean({
|
|
22
|
+
default: false,
|
|
23
|
+
description: "Preview the release's policy gate on this tenant without deploying; exits 2 when the deploy would be refused",
|
|
24
|
+
exclusive: ['override-reason'],
|
|
25
|
+
hidden: true,
|
|
26
|
+
}),
|
|
19
27
|
output: Flags.string({
|
|
20
28
|
char: 'o',
|
|
21
29
|
default: 'summary',
|
|
@@ -23,6 +31,11 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
23
31
|
options: ['summary', 'json'],
|
|
24
32
|
required: false,
|
|
25
33
|
}),
|
|
34
|
+
'override-reason': Flags.string({
|
|
35
|
+
description: 'Deploy past a blocking policy gate, recording this reason (needs the workspace:policy update permission; audited)',
|
|
36
|
+
exclusive: ['check'],
|
|
37
|
+
hidden: true,
|
|
38
|
+
}),
|
|
26
39
|
release: Flags.string({
|
|
27
40
|
char: 'r',
|
|
28
41
|
description: 'Release name to deploy',
|
|
@@ -34,6 +47,14 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
34
47
|
required: false,
|
|
35
48
|
}),
|
|
36
49
|
};
|
|
50
|
+
/** Set once the policy gate refuses the deploy, the one failure that exits 2. */
|
|
51
|
+
refusedByPolicyGate = false;
|
|
52
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
53
|
+
async catch(error) {
|
|
54
|
+
if (this.refusedByPolicyGate)
|
|
55
|
+
return super.catch(error);
|
|
56
|
+
return this.catchAsOperational(error);
|
|
57
|
+
}
|
|
37
58
|
async run() {
|
|
38
59
|
const { args, flags } = await this.parse(TenantDeployRelease);
|
|
39
60
|
const { profile } = this.resolveProfile(flags);
|
|
@@ -43,12 +64,46 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
43
64
|
}
|
|
44
65
|
const releaseName = flags.release;
|
|
45
66
|
const tenantName = args.tenant_name;
|
|
67
|
+
if (flags.check) {
|
|
68
|
+
await this.checkPolicyGate({ flags, profile, releaseName, tenantName, workspaceId });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const overrideReason = flags['override-reason']?.trim();
|
|
72
|
+
if (flags['override-reason'] !== undefined && !overrideReason) {
|
|
73
|
+
this.error('--override-reason needs a reason: say why this deploy may proceed past the policy gate.');
|
|
74
|
+
}
|
|
75
|
+
// Pre-flight: the "approval required" error must always be shown before any
|
|
76
|
+
// permission/RBAC error. The backend's own deploy route checks RBAC first
|
|
77
|
+
// and the approval gate second (inside mvp:tenant_deploy_release), so a
|
|
78
|
+
// caller who lacks deploy permission would otherwise never learn a request
|
|
79
|
+
// is what's actually needed. Checking here first, with an endpoint that
|
|
80
|
+
// only needs read scope, guarantees the right error is shown regardless of
|
|
81
|
+
// whether the caller can deploy at all.
|
|
82
|
+
const requiresApproval = await this.tenantRequiresApproval(profile, workspaceId, tenantName, flags.verbose);
|
|
83
|
+
if (requiresApproval) {
|
|
84
|
+
const hasApprovedRequest = await this.hasApprovedDeployRequest({
|
|
85
|
+
profile,
|
|
86
|
+
releaseName,
|
|
87
|
+
tenantName,
|
|
88
|
+
verbose: flags.verbose,
|
|
89
|
+
workspaceId,
|
|
90
|
+
});
|
|
91
|
+
if (!hasApprovedRequest) {
|
|
92
|
+
this.error(`Tenant "${tenantName}" requires an approved deploy request before "${releaseName}" can be deployed.\n` +
|
|
93
|
+
`Run: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
46
96
|
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
|
|
47
97
|
this.warn('This may take a few minutes. Please be patient.');
|
|
48
98
|
const startTime = Date.now();
|
|
99
|
+
// A refusal by the policy gate exits 2 with its verdict; the catch that rewords other failures keeps it.
|
|
100
|
+
let refused = null;
|
|
49
101
|
try {
|
|
50
102
|
const response = await this.verboseFetch(apiUrl, {
|
|
51
|
-
body: JSON.stringify({
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
release_name: releaseName,
|
|
105
|
+
...(overrideReason ? { override_policy: true, override_reason: overrideReason } : {}),
|
|
106
|
+
}),
|
|
52
107
|
headers: {
|
|
53
108
|
accept: 'application/json',
|
|
54
109
|
Authorization: `Bearer ${profile.access_token}`,
|
|
@@ -57,8 +112,22 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
57
112
|
method: 'POST',
|
|
58
113
|
}, flags.verbose, profile.access_token);
|
|
59
114
|
if (!response.ok) {
|
|
60
|
-
|
|
61
|
-
|
|
115
|
+
refused = await gateRefusal(response);
|
|
116
|
+
if (refused) {
|
|
117
|
+
const override = `xano tenant deploy_release ${quoted(tenantName)} --release ${quoted(releaseName)} --override-reason "<why>"`;
|
|
118
|
+
this.refuseByPolicyGate(refused, flags, override);
|
|
119
|
+
}
|
|
120
|
+
let message = await this.parseApiError(response, 'API request failed');
|
|
121
|
+
// Only reached once the pre-flight above has already confirmed the
|
|
122
|
+
// approval gate is satisfied or not applicable — so a permission error
|
|
123
|
+
// here is genuinely about RBAC, not about a missing approval. If the
|
|
124
|
+
// tenant supports deploy requests, mention that path as an alternative
|
|
125
|
+
// to direct deploy, since opening a request doesn't require deploy
|
|
126
|
+
// permission.
|
|
127
|
+
if ((response.status === 401 || response.status === 403) && requiresApproval) {
|
|
128
|
+
message += `\n\nYou don't have permission to deploy to this tenant directly, but it supports deploy requests: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`;
|
|
129
|
+
}
|
|
130
|
+
this.error(message);
|
|
62
131
|
}
|
|
63
132
|
const tenant = (await response.json());
|
|
64
133
|
if (flags.output === 'json') {
|
|
@@ -71,10 +140,15 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
71
140
|
this.log(` State: ${tenant.state}`);
|
|
72
141
|
if (tenant.release?.name)
|
|
73
142
|
this.log(` Release: ${tenant.release.name}`);
|
|
143
|
+
if (tenant.policy_gate?.status && tenant.policy_gate.status !== 'disabled')
|
|
144
|
+
this.log(` ${deployedGateLine(tenant.policy_gate)}`);
|
|
74
145
|
this.log(` Time: ${elapsed}s`);
|
|
75
146
|
}
|
|
147
|
+
this.warnPoliciesSkipped(tenant);
|
|
76
148
|
}
|
|
77
149
|
catch (error) {
|
|
150
|
+
if (refused)
|
|
151
|
+
throw error;
|
|
78
152
|
if (error instanceof Error) {
|
|
79
153
|
this.error(`Failed to deploy to tenant: ${error.message}`);
|
|
80
154
|
}
|
|
@@ -83,4 +157,108 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
83
157
|
}
|
|
84
158
|
}
|
|
85
159
|
}
|
|
160
|
+
/**
|
|
161
|
+
* `--check`: what the policy gate would decide for this release on this tenant, without deploying.
|
|
162
|
+
* Exits 2 when the deploy would be refused; a preview the platform cannot answer exits 1.
|
|
163
|
+
*/
|
|
164
|
+
async checkPolicyGate(opts) {
|
|
165
|
+
const { flags, profile, releaseName, tenantName, workspaceId } = opts;
|
|
166
|
+
const result = await fetchPolicyGate({ verboseFetch: (...args) => this.verboseFetch(...args) }, { profile, release: releaseName, tenant: tenantName, verbose: flags.verbose, workspace: workspaceId });
|
|
167
|
+
if ('unavailable' in result) {
|
|
168
|
+
const { message, payload, status } = result.unavailable;
|
|
169
|
+
this.error(`Policy gate preview failed (${status}): ${message}${policyPermissionGuidance(status, payload)}`);
|
|
170
|
+
}
|
|
171
|
+
const { answer } = result;
|
|
172
|
+
const exit = gateExitCode(answer);
|
|
173
|
+
if (flags.output === 'json') {
|
|
174
|
+
this.log(JSON.stringify(answer, null, 2));
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
this.log(`Checked release "${releaseName}" for tenant "${tenantName}"; nothing was deployed.`);
|
|
178
|
+
for (const line of gateLines(answer))
|
|
179
|
+
this.log(line);
|
|
180
|
+
if (exit) {
|
|
181
|
+
this.log(answer.can_override
|
|
182
|
+
? `To deploy past the gate: xano tenant deploy_release ${quoted(tenantName)} --release ${quoted(releaseName)} --override-reason "<why>"`
|
|
183
|
+
: 'A deploy is refused unless someone with the workspace:policy update permission overrides the gate.');
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (exit)
|
|
187
|
+
process.exitCode = exit;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Does an approved tenant_deploy_request already exist for this (tenant, release)
|
|
191
|
+
* pair? The list endpoint has no release filter, so this fetches approved
|
|
192
|
+
* requests for the tenant and filters client-side by the resolved release name.
|
|
193
|
+
*/
|
|
194
|
+
async hasApprovedDeployRequest(opts) {
|
|
195
|
+
const { profile, releaseName, tenantName, verbose, workspaceId } = opts;
|
|
196
|
+
const params = new URLSearchParams({ per_page: '100', status: 'approved', tenant_name: tenantName });
|
|
197
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request?${params}`;
|
|
198
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
199
|
+
headers: {
|
|
200
|
+
accept: 'application/json',
|
|
201
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
202
|
+
},
|
|
203
|
+
method: 'GET',
|
|
204
|
+
}, verbose, profile.access_token);
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
// A read-scope failure here means something is more broadly wrong (e.g.
|
|
207
|
+
// no workspace access at all) — surface it rather than silently treating
|
|
208
|
+
// it as "no approval found", which would misreport as approval-required.
|
|
209
|
+
const message = await this.parseApiError(response, 'Failed to check for an approved deploy request');
|
|
210
|
+
this.error(message);
|
|
211
|
+
}
|
|
212
|
+
const data = (await response.json());
|
|
213
|
+
const items = Array.isArray(data) ? data : (data.items ?? []);
|
|
214
|
+
return items.some((item) => item.status === 'approved' && item._release?.name === releaseName);
|
|
215
|
+
}
|
|
216
|
+
/** A deploy the policy gate refused: its verdict, then the refusal and how to proceed, exiting 2. */
|
|
217
|
+
refuseByPolicyGate(refused, flags, override) {
|
|
218
|
+
for (const line of gateRefusalOutput(refused, flags.output === 'json'))
|
|
219
|
+
this.log(line);
|
|
220
|
+
this.refusedByPolicyGate = true;
|
|
221
|
+
this.error(`${refused.message}${gateOverrideHint(refused.answer, override)}`, { exit: 2 });
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Does this tenant have the deploy approval gate enabled?
|
|
225
|
+
*
|
|
226
|
+
* Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
|
|
227
|
+
* tenant skip the approval gate entirely for quick deploys, regardless of
|
|
228
|
+
* `required_reviewers`.
|
|
229
|
+
*
|
|
230
|
+
* Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
|
|
231
|
+
* GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
|
|
232
|
+
* (which holds `required_reviewers`, `allow_deploy_bypass`, and
|
|
233
|
+
* `allow_quick_deploy`) even though the list route's output includes it
|
|
234
|
+
* — confirmed live against a running instance. Filed as a backend bug; this
|
|
235
|
+
* is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
|
|
236
|
+
*/
|
|
237
|
+
async tenantRequiresApproval(profile, workspaceId, tenantName, verbose) {
|
|
238
|
+
const params = new URLSearchParams({ per_page: '100' });
|
|
239
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${params}`;
|
|
240
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
241
|
+
headers: {
|
|
242
|
+
accept: 'application/json',
|
|
243
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
244
|
+
},
|
|
245
|
+
method: 'GET',
|
|
246
|
+
}, verbose, profile.access_token);
|
|
247
|
+
if (!response.ok) {
|
|
248
|
+
const message = await this.parseApiError(response, 'Failed to look up tenant');
|
|
249
|
+
this.error(message);
|
|
250
|
+
}
|
|
251
|
+
const data = (await response.json());
|
|
252
|
+
const items = Array.isArray(data) ? data : (data.items ?? []);
|
|
253
|
+
const tenant = items.find((item) => item.name === tenantName);
|
|
254
|
+
if (tenant?.deploy_settings?.allow_quick_deploy)
|
|
255
|
+
return false;
|
|
256
|
+
return (tenant?.deploy_settings?.required_reviewers ?? 0) > 0;
|
|
257
|
+
}
|
|
258
|
+
/** A release policy the tenant's import could not land (its key is held by another policy) is never dropped silently. */
|
|
259
|
+
warnPoliciesSkipped(tenant) {
|
|
260
|
+
const message = tenant.policies_skipped?.message;
|
|
261
|
+
if (message)
|
|
262
|
+
this.warn(message);
|
|
263
|
+
}
|
|
86
264
|
}
|
|
@@ -6,6 +6,7 @@ export default class TenantEdit extends BaseCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
allow_quick_deploy: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
10
|
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
11
|
display: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
12
|
domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -13,6 +14,7 @@ export default class TenantEdit extends BaseCommand {
|
|
|
13
14
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
15
|
proxy: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
16
|
rbac: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
|
+
required_reviewers: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
18
|
tasks: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
17
19
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
20
|
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -13,9 +13,15 @@ export default class TenantEdit extends BaseCommand {
|
|
|
13
13
|
Updated tenant: New Name (my-tenant) - ID: 42
|
|
14
14
|
`,
|
|
15
15
|
`$ xano tenant edit t1234-abcd-xyz1 --no-tasks --no-ingress -o json`,
|
|
16
|
+
`$ xano tenant edit t1234-abcd-xyz1 --required_reviewers 1`,
|
|
16
17
|
];
|
|
17
18
|
static flags = {
|
|
18
19
|
...BaseCommand.baseFlags,
|
|
20
|
+
allow_quick_deploy: Flags.boolean({
|
|
21
|
+
allowNo: true,
|
|
22
|
+
description: 'Allow this tenant to skip the deploy approval gate entirely for quick deploys, regardless of --required_reviewers.',
|
|
23
|
+
required: false,
|
|
24
|
+
}),
|
|
19
25
|
description: Flags.string({
|
|
20
26
|
char: 'd',
|
|
21
27
|
description: 'New description',
|
|
@@ -50,6 +56,10 @@ Updated tenant: New Name (my-tenant) - ID: 42
|
|
|
50
56
|
description: '[CRITICAL] NEVER disable RBAC without explicit user confirmation; this removes role-based access controls on the tenant. Enables/disables RBAC.',
|
|
51
57
|
required: false,
|
|
52
58
|
}),
|
|
59
|
+
required_reviewers: Flags.integer({
|
|
60
|
+
description: 'When greater than 0, that many distinct reviewers must approve a tenant_deploy_request before a release may be deployed to this tenant. 0 means not gated.',
|
|
61
|
+
required: false,
|
|
62
|
+
}),
|
|
53
63
|
tasks: Flags.boolean({
|
|
54
64
|
allowNo: true,
|
|
55
65
|
description: '[IMPORTANT] ALWAYS confirm with the user before disabling background tasks; this stops scheduled jobs on the tenant. Enables/disables background tasks.',
|
|
@@ -76,8 +86,17 @@ Updated tenant: New Name (my-tenant) - ID: 42
|
|
|
76
86
|
'Content-Type': 'application/json',
|
|
77
87
|
};
|
|
78
88
|
try {
|
|
79
|
-
// Fetch current tenant state (PUT requires all fields)
|
|
80
|
-
|
|
89
|
+
// Fetch current tenant state (PUT requires all fields). Deliberately uses
|
|
90
|
+
// `tenant list` rather than `tenant get`: the single-tenant GET route's
|
|
91
|
+
// output whitelist (tenant-detail.yaml) omits `deploy_settings` (which
|
|
92
|
+
// holds `required_reviewers`, `allow_deploy_bypass`, and
|
|
93
|
+
// `allow_quick_deploy`) even though the list route's output includes it
|
|
94
|
+
// — confirmed live against a running instance. Using GET here would
|
|
95
|
+
// silently reset those fields to their defaults on every edit that doesn't
|
|
96
|
+
// explicitly pass them. Filed as a backend bug; this is the workaround
|
|
97
|
+
// until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
|
|
98
|
+
const listParams = new URLSearchParams({ per_page: '100' });
|
|
99
|
+
const getResponse = await this.verboseFetch(`${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${listParams}`, {
|
|
81
100
|
headers: {
|
|
82
101
|
'accept': 'application/json',
|
|
83
102
|
'Authorization': `Bearer ${profile.access_token}`,
|
|
@@ -88,9 +107,25 @@ Updated tenant: New Name (my-tenant) - ID: 42
|
|
|
88
107
|
const errorText = await getResponse.text();
|
|
89
108
|
this.error(`Failed to fetch tenant: ${getResponse.status} ${getResponse.statusText}\n${errorText}`);
|
|
90
109
|
}
|
|
91
|
-
const
|
|
92
|
-
|
|
110
|
+
const listData = (await getResponse.json());
|
|
111
|
+
const listItems = Array.isArray(listData) ? listData : (listData.items ?? []);
|
|
112
|
+
const current = listItems.find((item) => item.name === tenantName);
|
|
113
|
+
if (!current) {
|
|
114
|
+
this.error(`Tenant "${tenantName}" not found`);
|
|
115
|
+
}
|
|
116
|
+
// Merge in user-provided values. allow_deploy_bypass has no edit flag (it's
|
|
117
|
+
// fixed at `tenant create` time), but the PUT still requires the full body,
|
|
118
|
+
// so its current value is preserved here rather than reset.
|
|
93
119
|
const body = {
|
|
120
|
+
deploy_settings: {
|
|
121
|
+
allow_deploy_bypass: current.deploy_settings?.allow_deploy_bypass ?? false,
|
|
122
|
+
allow_quick_deploy: flags.allow_quick_deploy === undefined
|
|
123
|
+
? (current.deploy_settings?.allow_quick_deploy ?? false)
|
|
124
|
+
: flags.allow_quick_deploy,
|
|
125
|
+
required_reviewers: flags.required_reviewers === undefined
|
|
126
|
+
? (current.deploy_settings?.required_reviewers ?? 0)
|
|
127
|
+
: flags.required_reviewers,
|
|
128
|
+
},
|
|
94
129
|
description: flags.description === undefined ? (current.description ?? '') : flags.description,
|
|
95
130
|
display: flags.display === undefined ? (current.display ?? current.name) : flags.display,
|
|
96
131
|
domain: flags.domain === undefined ? (current.domain ?? '') : flags.domain,
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class TenantDeployRequestBypass extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
id: import("@oclif/core/interfaces").Arg<number, {
|
|
5
|
+
max?: number;
|
|
6
|
+
min?: number;
|
|
7
|
+
}>;
|
|
8
|
+
};
|
|
9
|
+
static description: string;
|
|
10
|
+
static examples: string[];
|
|
11
|
+
static flags: {
|
|
12
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
'override-policy': import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
14
|
+
reason: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
/** Set once the policy gate refuses the bypass deploy, the one failure that exits 2. */
|
|
21
|
+
private refusedByPolicyGate;
|
|
22
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
23
|
+
protected catch(error: Error & {
|
|
24
|
+
oclif?: {
|
|
25
|
+
exit?: number;
|
|
26
|
+
};
|
|
27
|
+
}): Promise<void>;
|
|
28
|
+
run(): Promise<void>;
|
|
29
|
+
/** The bypass deploy was refused by the policy gate: its verdict, then the refusal, exiting 2. */
|
|
30
|
+
private refuseByPolicyGate;
|
|
31
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
import { gateOverrideHint, gateRefusal, gateRefusalOutput } from '../../../utils/policy/gate.js';
|
|
4
|
+
export default class TenantDeployRequestBypass extends BaseCommand {
|
|
5
|
+
static args = {
|
|
6
|
+
id: Args.integer({
|
|
7
|
+
description: 'Tenant deploy request ID',
|
|
8
|
+
required: true,
|
|
9
|
+
}),
|
|
10
|
+
};
|
|
11
|
+
static description = "[CRITICAL] STOP and confirm with the user before running this; it deploys the release immediately, past the review workflow. Deploy the request's release past the approval gate under the tenant_center:deploy:bypass_approval permission. Only available if the tenant has enabled bypass (allow_deploy_bypass), and every use is logged with the given reason. The approval request's own status is left untouched.";
|
|
12
|
+
static examples = [
|
|
13
|
+
`$ xano tenant_deploy_request bypass 12 --reason "Prod incident, reviewer unavailable"
|
|
14
|
+
Deployed tenant release, bypassing deploy request #12's approval gate.
|
|
15
|
+
`,
|
|
16
|
+
];
|
|
17
|
+
static flags = {
|
|
18
|
+
...BaseCommand.baseFlags,
|
|
19
|
+
output: Flags.string({
|
|
20
|
+
char: 'o',
|
|
21
|
+
default: 'summary',
|
|
22
|
+
description: 'Output format',
|
|
23
|
+
options: ['summary', 'json'],
|
|
24
|
+
required: false,
|
|
25
|
+
}),
|
|
26
|
+
'override-policy': Flags.boolean({
|
|
27
|
+
default: false,
|
|
28
|
+
description: 'Also proceed past a blocking policy gate, recording --reason as the override reason (needs the workspace:policy update permission; audited)',
|
|
29
|
+
hidden: true,
|
|
30
|
+
}),
|
|
31
|
+
reason: Flags.string({
|
|
32
|
+
description: 'Reason for bypassing the approval gate (required, audited)',
|
|
33
|
+
required: true,
|
|
34
|
+
}),
|
|
35
|
+
workspace: Flags.string({
|
|
36
|
+
char: 'w',
|
|
37
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
38
|
+
required: false,
|
|
39
|
+
}),
|
|
40
|
+
};
|
|
41
|
+
/** Set once the policy gate refuses the bypass deploy, the one failure that exits 2. */
|
|
42
|
+
refusedByPolicyGate = false;
|
|
43
|
+
/** Exit 2 is the policy gate's refusal; every other failure, flag errors included, exits 1. */
|
|
44
|
+
async catch(error) {
|
|
45
|
+
if (this.refusedByPolicyGate)
|
|
46
|
+
return super.catch(error);
|
|
47
|
+
return this.catchAsOperational(error);
|
|
48
|
+
}
|
|
49
|
+
async run() {
|
|
50
|
+
const { args, flags } = await this.parse(TenantDeployRequestBypass);
|
|
51
|
+
const { profile } = this.resolveProfile(flags);
|
|
52
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
53
|
+
if (!workspaceId) {
|
|
54
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
55
|
+
}
|
|
56
|
+
const body = {
|
|
57
|
+
reason: flags.reason,
|
|
58
|
+
...(flags['override-policy'] ? { override_policy: true } : {}),
|
|
59
|
+
};
|
|
60
|
+
// A refusal by the policy gate exits 2 with its verdict; the catch that rewords other failures keeps it.
|
|
61
|
+
let refused = null;
|
|
62
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request/${args.id}/override`;
|
|
63
|
+
try {
|
|
64
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
65
|
+
body: JSON.stringify(body),
|
|
66
|
+
headers: {
|
|
67
|
+
accept: 'application/json',
|
|
68
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
69
|
+
'Content-Type': 'application/json',
|
|
70
|
+
},
|
|
71
|
+
method: 'POST',
|
|
72
|
+
}, flags.verbose, profile.access_token);
|
|
73
|
+
if (!response.ok) {
|
|
74
|
+
refused = await gateRefusal(response);
|
|
75
|
+
if (refused)
|
|
76
|
+
this.refuseByPolicyGate(refused, args.id, flags.output === 'json');
|
|
77
|
+
const message = await this.parseApiError(response, 'Failed to bypass tenant deploy request approval gate');
|
|
78
|
+
this.error(message);
|
|
79
|
+
}
|
|
80
|
+
const deploy = (await response.json());
|
|
81
|
+
if (flags.output === 'json') {
|
|
82
|
+
this.log(JSON.stringify(deploy, null, 2));
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
this.log(`Deployed tenant release, bypassing deploy request #${args.id}'s approval gate.`);
|
|
86
|
+
if (deploy.duration_ms !== undefined)
|
|
87
|
+
this.log(` Time: ${(deploy.duration_ms / 1000).toFixed(1)}s`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (refused)
|
|
92
|
+
throw error;
|
|
93
|
+
if (error instanceof Error) {
|
|
94
|
+
this.error(`Failed to bypass tenant deploy request approval gate: ${error.message}`);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
this.error(`Failed to bypass tenant deploy request approval gate: ${String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/** The bypass deploy was refused by the policy gate: its verdict, then the refusal, exiting 2. */
|
|
102
|
+
refuseByPolicyGate(refused, id, json) {
|
|
103
|
+
for (const line of gateRefusalOutput(refused, json))
|
|
104
|
+
this.log(line);
|
|
105
|
+
const override = `xano tenant_deploy_request bypass ${id} --reason "<why>" --override-policy`;
|
|
106
|
+
this.refusedByPolicyGate = true;
|
|
107
|
+
this.error(`${refused.message}${gateOverrideHint(refused.answer, override)}`, { exit: 2 });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class TenantDeployRequestCreate extends BaseCommand {
|
|
3
|
+
static args: {
|
|
4
|
+
title: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
|
|
5
|
+
};
|
|
6
|
+
static description: string;
|
|
7
|
+
static examples: string[];
|
|
8
|
+
static flags: {
|
|
9
|
+
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
|
+
draft: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
11
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
12
|
+
release: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
reviewers: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
tenant: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
19
|
+
};
|
|
20
|
+
run(): Promise<void>;
|
|
21
|
+
}
|