@xano/cli 1.0.6-beta.19 → 1.0.6-beta.20
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 +57 -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 +21 -0
- package/dist/commands/tenant/deploy_release/index.js +94 -2
- 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 +20 -0
- package/dist/commands/tenant_deploy_request/bypass/index.js +79 -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 +19 -0
- package/dist/commands/tenant_deploy_request/get/index.js +75 -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 +21 -0
- package/dist/commands/tenant_deploy_request/set_status/index.js +112 -0
- package/oclif.manifest.json +4130 -3316
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -576,9 +576,12 @@ xano tenant get <tenant_name>
|
|
|
576
576
|
xano tenant create "My Tenant"
|
|
577
577
|
xano tenant create "My Tenant" -d "Description" --type tier2 --cluster_id 1 --platform_id 5
|
|
578
578
|
xano tenant create "My Tenant" --type tier2 --cluster_id 1 --license ./license.yaml
|
|
579
|
+
xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass
|
|
579
580
|
|
|
580
581
|
# Edit a tenant
|
|
581
582
|
xano tenant edit <tenant_name> --display "New Name" -d "New description"
|
|
583
|
+
xano tenant edit <tenant_name> --required_reviewers 1
|
|
584
|
+
xano tenant edit <tenant_name> --required_reviewers 0
|
|
582
585
|
|
|
583
586
|
# Delete a tenant (confirmation required)
|
|
584
587
|
xano tenant delete <tenant_name>
|
|
@@ -629,6 +632,60 @@ xano tenant deploy_release <tenant_name> --release v1.0
|
|
|
629
632
|
xano tenant deploy_platform <tenant_name> --platform_id 5 --license ./license.yaml
|
|
630
633
|
```
|
|
631
634
|
|
|
635
|
+
If a tenant has `required_reviewers` set above 0, `tenant deploy_release` fails with an
|
|
636
|
+
error naming the `tenant_deploy_request` command to run instead — see below.
|
|
637
|
+
|
|
638
|
+
#### Tenant Deploy Requests
|
|
639
|
+
|
|
640
|
+
When a tenant has `required_reviewers` set above 0 (see `tenant create`/`tenant edit`
|
|
641
|
+
above), a release may only be deployed to it through an **approved deploy request**,
|
|
642
|
+
requiring that many distinct reviewer approvals. An author with no deploy permission
|
|
643
|
+
opens a request; a named reviewer who does hold deploy permission on the tenant votes to
|
|
644
|
+
approve it, and once enough reviewers have approved, it deploys as part of that action.
|
|
645
|
+
|
|
646
|
+
```bash
|
|
647
|
+
# List deploy requests in a workspace (results show the target tenant and release)
|
|
648
|
+
xano tenant_deploy_request list
|
|
649
|
+
xano tenant_deploy_request list --tenant prod --status pending
|
|
650
|
+
xano tenant_deploy_request list --tenant prod --release v1.2
|
|
651
|
+
xano tenant_deploy_request list --to-review
|
|
652
|
+
|
|
653
|
+
# Get details of a specific deploy request
|
|
654
|
+
xano tenant_deploy_request get <id>
|
|
655
|
+
|
|
656
|
+
# Open a deploy request (submits for review immediately unless --draft is passed)
|
|
657
|
+
xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --reviewers 12,45
|
|
658
|
+
xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --draft
|
|
659
|
+
|
|
660
|
+
# Edit a deploy request's title, description or reviewers
|
|
661
|
+
xano tenant_deploy_request edit <id> --title "Deploy v1.2.1 to prod"
|
|
662
|
+
xano tenant_deploy_request edit <id> --reviewers 12,45,67
|
|
663
|
+
|
|
664
|
+
# Change status: draft, submit, approve, request_changes, close, or reopen
|
|
665
|
+
# (approve records one reviewer's vote; once enough votes are in it deploys the
|
|
666
|
+
# release as part of that same call)
|
|
667
|
+
xano tenant_deploy_request set_status <id> --status submit
|
|
668
|
+
xano tenant_deploy_request set_status <id> --status approve
|
|
669
|
+
xano tenant_deploy_request set_status <id> --status request_changes --reason "Needs a migration note"
|
|
670
|
+
xano tenant_deploy_request set_status <id> --status close --reason "Superseded by a newer request"
|
|
671
|
+
xano tenant_deploy_request set_status <id> --status reopen
|
|
672
|
+
|
|
673
|
+
# Link a newer release to a request after changes were requested (author only)
|
|
674
|
+
xano tenant_deploy_request revision <id> --release v1.2.1 --note "Added the missing migration"
|
|
675
|
+
|
|
676
|
+
# Deploy past the approval gate under the deploy-bypass permission (requires the
|
|
677
|
+
# tenant's allow_deploy_bypass to be enabled, and a separately-grantable RBAC
|
|
678
|
+
# permission). Every use is audited.
|
|
679
|
+
xano tenant_deploy_request bypass <id> --reason "Prod incident, reviewer unavailable"
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
CI example (e.g. a GitHub Action step):
|
|
683
|
+
|
|
684
|
+
```bash
|
|
685
|
+
xano tenant_deploy_request create "$PR_TITLE" --tenant prod --release v1.2 \
|
|
686
|
+
--description "$PR_BODY" --reviewers 12,45 -o json
|
|
687
|
+
```
|
|
688
|
+
|
|
632
689
|
#### Tenant License
|
|
633
690
|
|
|
634
691
|
```bash
|
|
@@ -6,6 +6,8 @@ export default class TenantCreate extends BaseCommand {
|
|
|
6
6
|
static description: string;
|
|
7
7
|
static examples: string[];
|
|
8
8
|
static flags: {
|
|
9
|
+
allow_deploy_bypass: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
10
|
+
allow_quick_deploy: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
9
11
|
cluster_id: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
10
12
|
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
11
13
|
domain: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -13,6 +15,7 @@ export default class TenantCreate extends BaseCommand {
|
|
|
13
15
|
license: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
16
|
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
17
|
platform_id: import("@oclif/core/interfaces").OptionFlag<number | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
required_reviewers: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
19
|
type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
20
|
tasks: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
21
|
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
@@ -17,9 +17,20 @@ Created tenant: Production (production) - ID: 42
|
|
|
17
17
|
`,
|
|
18
18
|
`$ xano tenant create "Staging" --description "Staging env" --cluster_id 1 --platform_id 1 --type tier2 -o json`,
|
|
19
19
|
`$ xano tenant create "Staging" --type tier2 --cluster_id 1 --license ./license.yaml`,
|
|
20
|
+
`$ xano tenant create "Production" --required_reviewers 1 --allow_deploy_bypass`,
|
|
20
21
|
];
|
|
21
22
|
static flags = {
|
|
22
23
|
...BaseCommand.baseFlags,
|
|
24
|
+
allow_deploy_bypass: Flags.boolean({
|
|
25
|
+
default: false,
|
|
26
|
+
description: 'Allow authorized users to bypass the deploy approval gate on this tenant (via tenant_deploy_request bypass). Has no effect unless --required_reviewers is also set above 0.',
|
|
27
|
+
required: false,
|
|
28
|
+
}),
|
|
29
|
+
allow_quick_deploy: Flags.boolean({
|
|
30
|
+
default: false,
|
|
31
|
+
description: 'Allow this tenant to skip the deploy approval gate entirely for quick deploys, regardless of --required_reviewers.',
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
23
34
|
cluster_id: Flags.integer({
|
|
24
35
|
description: 'Cluster ID to deploy to (required for tier2/tier3)',
|
|
25
36
|
required: false,
|
|
@@ -54,6 +65,11 @@ Created tenant: Production (production) - ID: 42
|
|
|
54
65
|
description: 'Platform ID to use',
|
|
55
66
|
required: false,
|
|
56
67
|
}),
|
|
68
|
+
required_reviewers: Flags.integer({
|
|
69
|
+
default: 0,
|
|
70
|
+
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 (default) means not gated.',
|
|
71
|
+
required: false,
|
|
72
|
+
}),
|
|
57
73
|
type: Flags.string({
|
|
58
74
|
default: 'tier1',
|
|
59
75
|
description: 'Tenant type',
|
|
@@ -79,6 +95,11 @@ Created tenant: Production (production) - ID: 42
|
|
|
79
95
|
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
80
96
|
}
|
|
81
97
|
const body = {
|
|
98
|
+
deploy_settings: {
|
|
99
|
+
allow_deploy_bypass: flags.allow_deploy_bypass,
|
|
100
|
+
allow_quick_deploy: flags.allow_quick_deploy,
|
|
101
|
+
required_reviewers: flags.required_reviewers,
|
|
102
|
+
},
|
|
82
103
|
display: args.display,
|
|
83
104
|
ingress: flags.ingress,
|
|
84
105
|
license: flags.type,
|
|
@@ -14,4 +14,25 @@ export default class TenantDeployRelease extends BaseCommand {
|
|
|
14
14
|
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
15
15
|
};
|
|
16
16
|
run(): Promise<void>;
|
|
17
|
+
/**
|
|
18
|
+
* Does an approved tenant_deploy_request already exist for this (tenant, release)
|
|
19
|
+
* pair? The list endpoint has no release filter, so this fetches approved
|
|
20
|
+
* requests for the tenant and filters client-side by the resolved release name.
|
|
21
|
+
*/
|
|
22
|
+
private hasApprovedDeployRequest;
|
|
23
|
+
/**
|
|
24
|
+
* Does this tenant have the deploy approval gate enabled?
|
|
25
|
+
*
|
|
26
|
+
* Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
|
|
27
|
+
* tenant skip the approval gate entirely for quick deploys, regardless of
|
|
28
|
+
* `required_reviewers`.
|
|
29
|
+
*
|
|
30
|
+
* Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
|
|
31
|
+
* GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
|
|
32
|
+
* (which holds `required_reviewers`, `allow_deploy_bypass`, and
|
|
33
|
+
* `allow_quick_deploy`) even though the list route's output includes it
|
|
34
|
+
* — confirmed live against a running instance. Filed as a backend bug; this
|
|
35
|
+
* is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
|
|
36
|
+
*/
|
|
37
|
+
private tenantRequiresApproval;
|
|
17
38
|
}
|
|
@@ -43,6 +43,27 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
43
43
|
}
|
|
44
44
|
const releaseName = flags.release;
|
|
45
45
|
const tenantName = args.tenant_name;
|
|
46
|
+
// Pre-flight: the "approval required" error must always be shown before any
|
|
47
|
+
// permission/RBAC error. The backend's own deploy route checks RBAC first
|
|
48
|
+
// and the approval gate second (inside mvp:tenant_deploy_release), so a
|
|
49
|
+
// caller who lacks deploy permission would otherwise never learn a request
|
|
50
|
+
// is what's actually needed. Checking here first, with an endpoint that
|
|
51
|
+
// only needs read scope, guarantees the right error is shown regardless of
|
|
52
|
+
// whether the caller can deploy at all.
|
|
53
|
+
const requiresApproval = await this.tenantRequiresApproval(profile, workspaceId, tenantName, flags.verbose);
|
|
54
|
+
if (requiresApproval) {
|
|
55
|
+
const hasApprovedRequest = await this.hasApprovedDeployRequest({
|
|
56
|
+
profile,
|
|
57
|
+
releaseName,
|
|
58
|
+
tenantName,
|
|
59
|
+
verbose: flags.verbose,
|
|
60
|
+
workspaceId,
|
|
61
|
+
});
|
|
62
|
+
if (!hasApprovedRequest) {
|
|
63
|
+
this.error(`Tenant "${tenantName}" requires an approved deploy request before "${releaseName}" can be deployed.\n` +
|
|
64
|
+
`Run: xano tenant_deploy_request create "Deploy ${releaseName} to ${tenantName}" --tenant ${tenantName} --release ${releaseName} --reviewers <id,id,...>`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
46
67
|
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant/${tenantName}/deploy`;
|
|
47
68
|
this.warn('This may take a few minutes. Please be patient.');
|
|
48
69
|
const startTime = Date.now();
|
|
@@ -57,8 +78,17 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
57
78
|
method: 'POST',
|
|
58
79
|
}, flags.verbose, profile.access_token);
|
|
59
80
|
if (!response.ok) {
|
|
60
|
-
|
|
61
|
-
|
|
81
|
+
let message = await this.parseApiError(response, 'API request failed');
|
|
82
|
+
// Only reached once the pre-flight above has already confirmed the
|
|
83
|
+
// approval gate is satisfied or not applicable — so a permission error
|
|
84
|
+
// here is genuinely about RBAC, not about a missing approval. If the
|
|
85
|
+
// tenant supports deploy requests, mention that path as an alternative
|
|
86
|
+
// to direct deploy, since opening a request doesn't require deploy
|
|
87
|
+
// permission.
|
|
88
|
+
if ((response.status === 401 || response.status === 403) && requiresApproval) {
|
|
89
|
+
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,...>`;
|
|
90
|
+
}
|
|
91
|
+
this.error(message);
|
|
62
92
|
}
|
|
63
93
|
const tenant = (await response.json());
|
|
64
94
|
if (flags.output === 'json') {
|
|
@@ -83,4 +113,66 @@ Deployed release "v1.0" to tenant: My Tenant (my-tenant)
|
|
|
83
113
|
}
|
|
84
114
|
}
|
|
85
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Does an approved tenant_deploy_request already exist for this (tenant, release)
|
|
118
|
+
* pair? The list endpoint has no release filter, so this fetches approved
|
|
119
|
+
* requests for the tenant and filters client-side by the resolved release name.
|
|
120
|
+
*/
|
|
121
|
+
async hasApprovedDeployRequest(opts) {
|
|
122
|
+
const { profile, releaseName, tenantName, verbose, workspaceId } = opts;
|
|
123
|
+
const params = new URLSearchParams({ per_page: '100', status: 'approved', tenant_name: tenantName });
|
|
124
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request?${params}`;
|
|
125
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
126
|
+
headers: {
|
|
127
|
+
accept: 'application/json',
|
|
128
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
129
|
+
},
|
|
130
|
+
method: 'GET',
|
|
131
|
+
}, verbose, profile.access_token);
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
// A read-scope failure here means something is more broadly wrong (e.g.
|
|
134
|
+
// no workspace access at all) — surface it rather than silently treating
|
|
135
|
+
// it as "no approval found", which would misreport as approval-required.
|
|
136
|
+
const message = await this.parseApiError(response, 'Failed to check for an approved deploy request');
|
|
137
|
+
this.error(message);
|
|
138
|
+
}
|
|
139
|
+
const data = (await response.json());
|
|
140
|
+
const items = Array.isArray(data) ? data : (data.items ?? []);
|
|
141
|
+
return items.some((item) => item.status === 'approved' && item._release?.name === releaseName);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Does this tenant have the deploy approval gate enabled?
|
|
145
|
+
*
|
|
146
|
+
* Returns false if the tenant has `allow_quick_deploy` set — that flag lets a
|
|
147
|
+
* tenant skip the approval gate entirely for quick deploys, regardless of
|
|
148
|
+
* `required_reviewers`.
|
|
149
|
+
*
|
|
150
|
+
* Deliberately uses `tenant list` rather than `tenant get`: the single-tenant
|
|
151
|
+
* GET route's output whitelist (tenant-detail.yaml) omits `deploy_settings`
|
|
152
|
+
* (which holds `required_reviewers`, `allow_deploy_bypass`, and
|
|
153
|
+
* `allow_quick_deploy`) even though the list route's output includes it
|
|
154
|
+
* — confirmed live against a running instance. Filed as a backend bug; this
|
|
155
|
+
* is the workaround until it's fixed. See TENANT_DEPLOY_REQUEST_TEST.md.
|
|
156
|
+
*/
|
|
157
|
+
async tenantRequiresApproval(profile, workspaceId, tenantName, verbose) {
|
|
158
|
+
const params = new URLSearchParams({ per_page: '100' });
|
|
159
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/tenant?${params}`;
|
|
160
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
161
|
+
headers: {
|
|
162
|
+
accept: 'application/json',
|
|
163
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
164
|
+
},
|
|
165
|
+
method: 'GET',
|
|
166
|
+
}, verbose, profile.access_token);
|
|
167
|
+
if (!response.ok) {
|
|
168
|
+
const message = await this.parseApiError(response, 'Failed to look up tenant');
|
|
169
|
+
this.error(message);
|
|
170
|
+
}
|
|
171
|
+
const data = (await response.json());
|
|
172
|
+
const items = Array.isArray(data) ? data : (data.items ?? []);
|
|
173
|
+
const tenant = items.find((item) => item.name === tenantName);
|
|
174
|
+
if (tenant?.deploy_settings?.allow_quick_deploy)
|
|
175
|
+
return false;
|
|
176
|
+
return (tenant?.deploy_settings?.required_reviewers ?? 0) > 0;
|
|
177
|
+
}
|
|
86
178
|
}
|
|
@@ -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
|
+
? flags.allow_quick_deploy
|
|
124
|
+
: (current.deploy_settings?.allow_quick_deploy ?? false),
|
|
125
|
+
required_reviewers: flags.required_reviewers !== undefined
|
|
126
|
+
? flags.required_reviewers
|
|
127
|
+
: (current.deploy_settings?.required_reviewers ?? 0),
|
|
128
|
+
},
|
|
94
129
|
description: flags.description !== undefined ? flags.description : (current.description ?? ''),
|
|
95
130
|
display: flags.display !== undefined ? flags.display : (current.display ?? current.name),
|
|
96
131
|
domain: flags.domain !== undefined ? flags.domain : (current.domain ?? ''),
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
reason: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
18
|
+
};
|
|
19
|
+
run(): Promise<void>;
|
|
20
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class TenantDeployRequestBypass extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
id: Args.integer({
|
|
6
|
+
description: 'Tenant deploy request ID',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
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.";
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano tenant_deploy_request bypass 12 --reason "Prod incident, reviewer unavailable"
|
|
13
|
+
Deployed tenant release, bypassing deploy request #12's approval gate.
|
|
14
|
+
`,
|
|
15
|
+
];
|
|
16
|
+
static flags = {
|
|
17
|
+
...BaseCommand.baseFlags,
|
|
18
|
+
output: Flags.string({
|
|
19
|
+
char: 'o',
|
|
20
|
+
default: 'summary',
|
|
21
|
+
description: 'Output format',
|
|
22
|
+
options: ['summary', 'json'],
|
|
23
|
+
required: false,
|
|
24
|
+
}),
|
|
25
|
+
reason: Flags.string({
|
|
26
|
+
description: 'Reason for bypassing the approval gate (required, audited)',
|
|
27
|
+
required: true,
|
|
28
|
+
}),
|
|
29
|
+
workspace: Flags.string({
|
|
30
|
+
char: 'w',
|
|
31
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
32
|
+
required: false,
|
|
33
|
+
}),
|
|
34
|
+
};
|
|
35
|
+
async run() {
|
|
36
|
+
const { args, flags } = await this.parse(TenantDeployRequestBypass);
|
|
37
|
+
const { profile } = this.resolveProfile(flags);
|
|
38
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
39
|
+
if (!workspaceId) {
|
|
40
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
41
|
+
}
|
|
42
|
+
const body = {
|
|
43
|
+
reason: flags.reason,
|
|
44
|
+
};
|
|
45
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request/${args.id}/override`;
|
|
46
|
+
try {
|
|
47
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
48
|
+
body: JSON.stringify(body),
|
|
49
|
+
headers: {
|
|
50
|
+
accept: 'application/json',
|
|
51
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
52
|
+
'Content-Type': 'application/json',
|
|
53
|
+
},
|
|
54
|
+
method: 'POST',
|
|
55
|
+
}, flags.verbose, profile.access_token);
|
|
56
|
+
if (!response.ok) {
|
|
57
|
+
const message = await this.parseApiError(response, 'Failed to bypass tenant deploy request approval gate');
|
|
58
|
+
this.error(message);
|
|
59
|
+
}
|
|
60
|
+
const deploy = (await response.json());
|
|
61
|
+
if (flags.output === 'json') {
|
|
62
|
+
this.log(JSON.stringify(deploy, null, 2));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
this.log(`Deployed tenant release, bypassing deploy request #${args.id}'s approval gate.`);
|
|
66
|
+
if (deploy.duration_ms !== undefined)
|
|
67
|
+
this.log(` Time: ${(deploy.duration_ms / 1000).toFixed(1)}s`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
this.error(`Failed to bypass tenant deploy request approval gate: ${error.message}`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
this.error(`Failed to bypass tenant deploy request approval gate: ${String(error)}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Args, Flags } from '@oclif/core';
|
|
2
|
+
import BaseCommand from '../../../base-command.js';
|
|
3
|
+
export default class TenantDeployRequestCreate extends BaseCommand {
|
|
4
|
+
static args = {
|
|
5
|
+
title: Args.string({
|
|
6
|
+
description: 'Short title for the deploy request',
|
|
7
|
+
required: true,
|
|
8
|
+
}),
|
|
9
|
+
};
|
|
10
|
+
static description = 'Open a tenant deploy request asking named reviewers to approve deploying a release to a tenant. Submits for review immediately unless --draft is passed.';
|
|
11
|
+
static examples = [
|
|
12
|
+
`$ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --reviewers 12,45
|
|
13
|
+
Created deploy request #12: "Deploy v1.2 to prod" [pending]
|
|
14
|
+
`,
|
|
15
|
+
`$ xano tenant_deploy_request create "Deploy v1.2 to prod" --tenant prod --release v1.2 --draft`,
|
|
16
|
+
`$ xano tenant_deploy_request create "$PR_TITLE" --tenant prod --release v1.2 --description "$PR_BODY" --reviewers 12,45 -o json`,
|
|
17
|
+
];
|
|
18
|
+
static flags = {
|
|
19
|
+
...BaseCommand.baseFlags,
|
|
20
|
+
description: Flags.string({
|
|
21
|
+
char: 'd',
|
|
22
|
+
description: 'Longer description of what is being deployed and why',
|
|
23
|
+
required: false,
|
|
24
|
+
}),
|
|
25
|
+
draft: Flags.boolean({
|
|
26
|
+
default: false,
|
|
27
|
+
description: 'Save as a draft instead of submitting immediately (reviewers are not notified until submitted)',
|
|
28
|
+
required: false,
|
|
29
|
+
}),
|
|
30
|
+
output: Flags.string({
|
|
31
|
+
char: 'o',
|
|
32
|
+
default: 'summary',
|
|
33
|
+
description: 'Output format',
|
|
34
|
+
options: ['summary', 'json'],
|
|
35
|
+
required: false,
|
|
36
|
+
}),
|
|
37
|
+
release: Flags.string({
|
|
38
|
+
char: 'r',
|
|
39
|
+
description: 'Name of the release to deploy',
|
|
40
|
+
required: true,
|
|
41
|
+
}),
|
|
42
|
+
reviewers: Flags.string({
|
|
43
|
+
description: 'Comma-separated user IDs to request review from',
|
|
44
|
+
required: false,
|
|
45
|
+
}),
|
|
46
|
+
tenant: Flags.string({
|
|
47
|
+
char: 't',
|
|
48
|
+
description: 'Name of the target tenant',
|
|
49
|
+
required: true,
|
|
50
|
+
}),
|
|
51
|
+
workspace: Flags.string({
|
|
52
|
+
char: 'w',
|
|
53
|
+
description: 'Workspace ID (uses profile workspace if not provided)',
|
|
54
|
+
required: false,
|
|
55
|
+
}),
|
|
56
|
+
};
|
|
57
|
+
async run() {
|
|
58
|
+
const { args, flags } = await this.parse(TenantDeployRequestCreate);
|
|
59
|
+
const { profile } = this.resolveProfile(flags);
|
|
60
|
+
const workspaceId = flags.workspace || profile.workspace;
|
|
61
|
+
if (!workspaceId) {
|
|
62
|
+
this.error('No workspace ID provided. Use --workspace flag or set one in your profile.');
|
|
63
|
+
}
|
|
64
|
+
const reviewerIds = flags.reviewers
|
|
65
|
+
? flags.reviewers.split(',').map((id) => Number.parseInt(id.trim(), 10))
|
|
66
|
+
: [];
|
|
67
|
+
const body = {
|
|
68
|
+
release_name: flags.release,
|
|
69
|
+
reviewer_ids: reviewerIds,
|
|
70
|
+
status: flags.draft ? 'draft' : 'pending',
|
|
71
|
+
tenant_name: flags.tenant,
|
|
72
|
+
title: args.title,
|
|
73
|
+
};
|
|
74
|
+
if (flags.description)
|
|
75
|
+
body.description = flags.description;
|
|
76
|
+
const apiUrl = `${profile.instance_origin}/api:meta/workspace/${workspaceId}/approval_request`;
|
|
77
|
+
try {
|
|
78
|
+
const response = await this.verboseFetch(apiUrl, {
|
|
79
|
+
body: JSON.stringify(body),
|
|
80
|
+
headers: {
|
|
81
|
+
accept: 'application/json',
|
|
82
|
+
Authorization: `Bearer ${profile.access_token}`,
|
|
83
|
+
'Content-Type': 'application/json',
|
|
84
|
+
},
|
|
85
|
+
method: 'POST',
|
|
86
|
+
}, flags.verbose, profile.access_token);
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
const message = await this.parseApiError(response, 'Failed to create tenant deploy request');
|
|
89
|
+
this.error(message);
|
|
90
|
+
}
|
|
91
|
+
const item = (await response.json());
|
|
92
|
+
if (flags.output === 'json') {
|
|
93
|
+
this.log(JSON.stringify(item, null, 2));
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
this.log(`Created deploy request #${item.id}: "${item.title}" [${item.status}]`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (error instanceof Error) {
|
|
101
|
+
this.error(`Failed to create tenant deploy request: ${error.message}`);
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
this.error(`Failed to create tenant deploy request: ${String(error)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import BaseCommand from '../../../base-command.js';
|
|
2
|
+
export default class TenantDeployRequestEdit 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
|
+
description: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
13
|
+
output: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
|
|
14
|
+
reviewers: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
15
|
+
title: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
16
|
+
workspace: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
17
|
+
config: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
18
|
+
profile: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
|
|
19
|
+
verbose: import("@oclif/core/interfaces").BooleanFlag<boolean>;
|
|
20
|
+
};
|
|
21
|
+
run(): Promise<void>;
|
|
22
|
+
}
|