@gambi97/keel-cli 0.3.5 → 0.3.7
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 +9 -1
- package/dist/bootstrap/github.js +20 -44
- package/dist/bootstrap/infisical.js +9 -2
- package/dist/bootstrap/scaleway.js +56 -5
- package/dist/config.js +14 -5
- package/dist/generate.js +6 -0
- package/dist/index.js +11 -3
- package/dist/prompts.js +7 -2
- package/dist/state.js +8 -3
- package/dist/ui.js +1 -1
- package/package.json +1 -1
- package/templates/README.md +4 -0
package/README.md
CHANGED
|
@@ -263,6 +263,12 @@ creating anything**.
|
|
|
263
263
|
`ContainersFullAccess`, `ServerlessSQLDatabaseFullAccess`,
|
|
264
264
|
`ContainerRegistryFullAccess`, plus `IAMManager` so Terraform can create
|
|
265
265
|
the app's dedicated least-privilege database credential).
|
|
266
|
+
5. **Organization security settings — API-key expiration must be unlimited**
|
|
267
|
+
(Console → Organization → Security → API keys). The app's database and
|
|
268
|
+
Object Storage credentials are non-expiring service credentials by design
|
|
269
|
+
(the container reads them at runtime); if your organization forces API
|
|
270
|
+
keys to expire, the first CI apply fails creating them. keel checks this
|
|
271
|
+
setting during validation and warns before anything is created.
|
|
266
272
|
|
|
267
273
|
</details>
|
|
268
274
|
|
|
@@ -273,7 +279,9 @@ creating anything**.
|
|
|
273
279
|
1. Create an account at [app.infisical.com](https://app.infisical.com) (or
|
|
274
280
|
use a self-hosted instance).
|
|
275
281
|
2. Create a **Machine Identity** with **Universal Auth**: you need its
|
|
276
|
-
**client ID** and **client secret**.
|
|
282
|
+
**client ID** and **client secret**. Create the client secret with
|
|
283
|
+
**unlimited uses** (Max Number of Uses = 0): CI logs in with it on every
|
|
284
|
+
plan/apply, so any finite usage limit eventually runs out mid-pipeline.
|
|
277
285
|
3. Give the identity permission to create and manage projects.
|
|
278
286
|
|
|
279
287
|
</details>
|
package/dist/bootstrap/github.js
CHANGED
|
@@ -24,18 +24,21 @@ export class GitHubError extends Error {
|
|
|
24
24
|
export async function authenticate(token) {
|
|
25
25
|
// Silence Octokit's own request logging: expected non-2xx responses (a 404
|
|
26
26
|
// for a repo that does not exist yet, a 401 for a bad token) are handled
|
|
27
|
-
// here and would otherwise scribble over the prompt spinner.
|
|
27
|
+
// here and would otherwise scribble over the prompt spinner. The same sink
|
|
28
|
+
// is passed at the request layer too: @octokit/request reads
|
|
29
|
+
// `request.log` (falling back to console) for deprecation notices carried
|
|
30
|
+
// in response headers, bypassing the instance logger.
|
|
31
|
+
const silentLog = { debug: () => { }, info: () => { }, warn: () => { }, error: () => { } };
|
|
28
32
|
const octokit = new Octokit({
|
|
29
33
|
auth: token,
|
|
30
|
-
log:
|
|
34
|
+
log: silentLog,
|
|
35
|
+
request: { log: silentLog },
|
|
31
36
|
});
|
|
32
37
|
let login;
|
|
33
|
-
let ownerId;
|
|
34
38
|
let scopes;
|
|
35
39
|
try {
|
|
36
40
|
const { data, headers } = await octokit.users.getAuthenticated();
|
|
37
41
|
login = data.login;
|
|
38
|
-
ownerId = data.id;
|
|
39
42
|
scopes = headers['x-oauth-scopes'];
|
|
40
43
|
}
|
|
41
44
|
catch {
|
|
@@ -53,7 +56,7 @@ export async function authenticate(token) {
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
|
-
return { octokit, owner: login
|
|
59
|
+
return { octokit, owner: login };
|
|
57
60
|
}
|
|
58
61
|
export async function createContext(github) {
|
|
59
62
|
const identity = await authenticate(github.token);
|
|
@@ -209,11 +212,8 @@ export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
|
209
212
|
INFISICAL_HOST: answers.infisical.host,
|
|
210
213
|
};
|
|
211
214
|
await setVariables(ctx, variables);
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await protectMainBranch(ctx, answers),
|
|
215
|
-
].filter((w) => w !== undefined);
|
|
216
|
-
return warnings.length > 0 ? warnings.join('\n\n') : undefined;
|
|
215
|
+
await configureEnvironments(ctx, answers);
|
|
216
|
+
return protectMainBranch(ctx, answers);
|
|
217
217
|
}
|
|
218
218
|
async function setSecrets(ctx, secrets) {
|
|
219
219
|
await sodium.ready;
|
|
@@ -255,44 +255,20 @@ async function setVariables(ctx, variables) {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
/**
|
|
258
|
-
* Each environment gets a GitHub deployment environment
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
262
|
-
*
|
|
263
|
-
* auto-deploy on merge. Like the branch-protection miss, this degrades to a
|
|
264
|
-
* warning instead of killing the run.
|
|
258
|
+
* Each environment gets a GitHub deployment environment, used by the apply
|
|
259
|
+
* workflow for deployment tracking and history. No reviewer gate is requested:
|
|
260
|
+
* production is promoted by a version tag — the tag IS the gate, free on every
|
|
261
|
+
* plan, while required reviewers would need GitHub Enterprise Cloud on private
|
|
262
|
+
* repositories and would only duplicate what the tag already enforces.
|
|
265
263
|
*/
|
|
266
264
|
export async function configureEnvironments(ctx, answers) {
|
|
267
|
-
const ungated = [];
|
|
268
265
|
for (const env of answers.environments) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
...(env.gated ? { reviewers: [{ type: 'User', id: ctx.ownerId }] } : {}),
|
|
275
|
-
});
|
|
276
|
-
}
|
|
277
|
-
catch (error) {
|
|
278
|
-
// Only the reviewer rule can trip the plan limit; re-throw anything else
|
|
279
|
-
// (and any failure on a non-gated environment, which asked for no rule).
|
|
280
|
-
if (!env.gated || error.status !== 422)
|
|
281
|
-
throw error;
|
|
282
|
-
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
283
|
-
owner: ctx.owner,
|
|
284
|
-
repo: ctx.repo,
|
|
285
|
-
environment_name: env.githubEnvironment,
|
|
286
|
-
});
|
|
287
|
-
ungated.push(env.githubEnvironment);
|
|
288
|
-
}
|
|
266
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
267
|
+
owner: ctx.owner,
|
|
268
|
+
repo: ctx.repo,
|
|
269
|
+
environment_name: env.githubEnvironment,
|
|
270
|
+
});
|
|
289
271
|
}
|
|
290
|
-
if (ungated.length === 0)
|
|
291
|
-
return undefined;
|
|
292
|
-
return (`Created the ${ungated.join(', ')} environment(s) without the manual-approval gate: ` +
|
|
293
|
-
'required reviewers need GitHub Enterprise Cloud on private repositories, so production ' +
|
|
294
|
-
'will auto-deploy on merge to main. Make the repository public (reviewers are free there) ' +
|
|
295
|
-
'or upgrade the plan, then re-run to add the gate.');
|
|
296
272
|
}
|
|
297
273
|
/**
|
|
298
274
|
* Require a green plan on PRs and forbid force-pushes/deletion of main.
|
|
@@ -30,8 +30,15 @@ export async function login(infisical) {
|
|
|
30
30
|
const { host, clientId, clientSecret } = infisical;
|
|
31
31
|
const { status, data } = await api(host, '/api/v1/auth/universal-auth/login', { method: 'POST', body: { clientId, clientSecret } });
|
|
32
32
|
if (status !== 200 || !data.accessToken) {
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// A usage-limited client secret is a trap for this setup: besides the
|
|
34
|
+
// bootstrap, CI logs in with it on every plan/apply, so any finite limit
|
|
35
|
+
// eventually runs out. Name the fix instead of a generic "check secret".
|
|
36
|
+
const remediation = /usage limit/i.test(data.message ?? '')
|
|
37
|
+
? 'This client secret was created with a usage limit and it is spent. Create a new one ' +
|
|
38
|
+
'with unlimited uses (Max Number of Uses = 0): CI logs in with it on every plan/apply, ' +
|
|
39
|
+
'so a limited secret will always run out.'
|
|
40
|
+
: 'Check the machine identity client ID/secret and that Universal Auth is enabled.';
|
|
41
|
+
throw new InfisicalError(`Infisical Universal Auth login failed (HTTP ${status}${data.message ? `: ${data.message}` : ''}). ${remediation}`);
|
|
35
42
|
}
|
|
36
43
|
return data.accessToken;
|
|
37
44
|
}
|
|
@@ -7,10 +7,38 @@ export class ScalewayError extends Error {
|
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
9
|
const API_BASE = 'https://api.scaleway.com';
|
|
10
|
+
/**
|
|
11
|
+
* The generated stack creates non-expiring service credentials (the app's
|
|
12
|
+
* database and Object Storage API keys are read by the container at runtime).
|
|
13
|
+
* An organization security setting that forces API-key expiration makes the
|
|
14
|
+
* very first CI apply fail — hours after the bootstrap looked green. Read the
|
|
15
|
+
* setting here (read-only) so the run can warn upfront, while it is still a
|
|
16
|
+
* 30-second console fix. Returns undefined when the key cannot read it.
|
|
17
|
+
*/
|
|
18
|
+
async function apiKeyExpirationPolicyWarning(secretKey, organizationId) {
|
|
19
|
+
try {
|
|
20
|
+
const response = await fetch(`${API_BASE}/iam/v1alpha1/organizations/${organizationId}/security-settings`, { headers: { 'X-Auth-Token': secretKey } });
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
return undefined;
|
|
23
|
+
const settings = (await response.json());
|
|
24
|
+
const seconds = Number.parseInt(settings.max_api_key_expiration_duration ?? '0', 10);
|
|
25
|
+
if (!Number.isFinite(seconds) || seconds <= 0)
|
|
26
|
+
return undefined; // "0s" = unlimited
|
|
27
|
+
return ('Your Scaleway organization requires API keys to expire ' +
|
|
28
|
+
`(max ${Math.round(seconds / 86400)} days). The database/storage credentials the ` +
|
|
29
|
+
'generated stack creates are non-expiring service credentials, so the first CI apply ' +
|
|
30
|
+
'WILL fail on them. Disable the requirement first (Console → Organization → Security → ' +
|
|
31
|
+
'API keys), then merge or re-run the apply.');
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
10
37
|
/**
|
|
11
38
|
* Validate credentials with a harmless read call before creating anything.
|
|
12
39
|
* Also confirms the project belongs to the given organization. Read-only: it
|
|
13
|
-
* proves the key can see the project, not that it can create resources.
|
|
40
|
+
* proves the key can see the project, not that it can create resources. The
|
|
41
|
+
* returned warning flags an org policy that would break the first CI apply.
|
|
14
42
|
*/
|
|
15
43
|
export async function validateScalewayCredentials(scaleway) {
|
|
16
44
|
const { secretKey, projectId, organizationId } = scaleway;
|
|
@@ -32,6 +60,28 @@ export async function validateScalewayCredentials(scaleway) {
|
|
|
32
60
|
throw new ScalewayError(`Scaleway project ${projectId} belongs to organization ${project.organization_id}, ` +
|
|
33
61
|
`not ${organizationId}. Check --scw-organization-id.`, 'organization');
|
|
34
62
|
}
|
|
63
|
+
const warning = await apiKeyExpirationPolicyWarning(secretKey, organizationId);
|
|
64
|
+
return warning ? { warning } : {};
|
|
65
|
+
}
|
|
66
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
67
|
+
/**
|
|
68
|
+
* Bucket-metadata calls made right after CreateBucket can race its propagation
|
|
69
|
+
* and answer NoSuchBucket for a bucket that provably exists (we created or
|
|
70
|
+
* Head-checked it moments before). Retry exactly that error with a short
|
|
71
|
+
* backoff; anything else is a real failure and is thrown immediately.
|
|
72
|
+
*/
|
|
73
|
+
export async function retryWhileBucketPropagates(fn, attempts = 5, baseDelayMs = 1000) {
|
|
74
|
+
for (let attempt = 1;; attempt++) {
|
|
75
|
+
try {
|
|
76
|
+
return await fn();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (error.name !== 'NoSuchBucket' || attempt >= attempts) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
await sleep(attempt * baseDelayMs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
35
85
|
}
|
|
36
86
|
function s3Client(answers) {
|
|
37
87
|
return new S3Client({
|
|
@@ -107,13 +157,14 @@ async function lockDownStateBucket(client, bucket, answers) {
|
|
|
107
157
|
],
|
|
108
158
|
};
|
|
109
159
|
try {
|
|
110
|
-
await client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) }));
|
|
160
|
+
await retryWhileBucketPropagates(() => client.send(new PutBucketPolicyCommand({ Bucket: bucket, Policy: JSON.stringify(policy) })));
|
|
111
161
|
return undefined;
|
|
112
162
|
}
|
|
113
163
|
catch (error) {
|
|
114
164
|
return (`Could not apply a policy to bucket "${bucket}" ` +
|
|
115
165
|
`(${error instanceof Error ? error.message : String(error)}); ` +
|
|
116
|
-
'the state bucket was NOT restricted to your identity.'
|
|
166
|
+
'the state bucket was NOT restricted to your identity. ' +
|
|
167
|
+
'Re-run keel to retry this step.');
|
|
117
168
|
}
|
|
118
169
|
}
|
|
119
170
|
/**
|
|
@@ -144,10 +195,10 @@ export async function ensureStateBucket(answers) {
|
|
|
144
195
|
try {
|
|
145
196
|
await client.send(new CreateBucketCommand({ Bucket: bucket }));
|
|
146
197
|
// Versioning protects the state history against accidental overwrites.
|
|
147
|
-
await client.send(new PutBucketVersioningCommand({
|
|
198
|
+
await retryWhileBucketPropagates(() => client.send(new PutBucketVersioningCommand({
|
|
148
199
|
Bucket: bucket,
|
|
149
200
|
VersioningConfiguration: { Status: 'Enabled' },
|
|
150
|
-
}));
|
|
201
|
+
})));
|
|
151
202
|
created = true;
|
|
152
203
|
}
|
|
153
204
|
catch (createError) {
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,6 @@ const ENV_DEFAULTS = {
|
|
|
11
11
|
displayName: 'Development',
|
|
12
12
|
githubEnvironment: 'dev',
|
|
13
13
|
production: false,
|
|
14
|
-
gated: false,
|
|
15
14
|
minScale: 0,
|
|
16
15
|
maxScale: 1,
|
|
17
16
|
},
|
|
@@ -19,7 +18,6 @@ const ENV_DEFAULTS = {
|
|
|
19
18
|
displayName: 'Staging',
|
|
20
19
|
githubEnvironment: 'staging',
|
|
21
20
|
production: false,
|
|
22
|
-
gated: false,
|
|
23
21
|
minScale: 0,
|
|
24
22
|
maxScale: 1,
|
|
25
23
|
},
|
|
@@ -27,7 +25,6 @@ const ENV_DEFAULTS = {
|
|
|
27
25
|
displayName: 'Production',
|
|
28
26
|
githubEnvironment: 'production',
|
|
29
27
|
production: true,
|
|
30
|
-
gated: true,
|
|
31
28
|
minScale: 0,
|
|
32
29
|
// Start small: one instance is enough to go live and costs the least;
|
|
33
30
|
// raising the ceiling later is a one-line prod.tfvars change.
|
|
@@ -50,12 +47,25 @@ export const DEFAULT_ENV_PRESET = 'staging+prod';
|
|
|
50
47
|
* Lock a resume's configuration to what the repo was generated with. Scaling
|
|
51
48
|
* is intentionally not carried: it only shapes the tfvars, which are already
|
|
52
49
|
* generated on any run that has a manifest, so post-generate steps never read it.
|
|
50
|
+
*
|
|
51
|
+
* The GitHub repo identity is locked too: on resume the target repository is
|
|
52
|
+
* already decided (keel created and pushed it), so the picker must not run and
|
|
53
|
+
* must never frame the project's own — now non-empty — repo as "create a new
|
|
54
|
+
* one". Older manifests without a github block fall back to the project name,
|
|
55
|
+
* which is the repo default and the only repository the project could own. An
|
|
56
|
+
* explicit --repo-name still wins (the established flag-precedence rule).
|
|
53
57
|
*/
|
|
54
58
|
export function hydrateConfigFromManifest(partial, manifest) {
|
|
55
59
|
partial.region = manifest.region;
|
|
56
60
|
partial.environments = manifest.environments;
|
|
57
61
|
partial.objectStorage = manifest.options.objectStorage;
|
|
58
62
|
partial.basicAuth = manifest.options.basicAuth;
|
|
63
|
+
if (!partial.github.repoName) {
|
|
64
|
+
partial.github.repoName = manifest.github?.repoName ?? manifest.projectName;
|
|
65
|
+
}
|
|
66
|
+
if (partial.github.repoPrivate === undefined && manifest.github?.repoPrivate !== undefined) {
|
|
67
|
+
partial.github.repoPrivate = manifest.github.repoPrivate;
|
|
68
|
+
}
|
|
59
69
|
}
|
|
60
70
|
export class ConfigError extends Error {
|
|
61
71
|
}
|
|
@@ -210,8 +220,7 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
210
220
|
displayName: def.displayName,
|
|
211
221
|
githubEnvironment: def.githubEnvironment,
|
|
212
222
|
production: def.production,
|
|
213
|
-
|
|
214
|
-
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
223
|
+
// Basic Auth is a non-production safety net; production never has it.
|
|
215
224
|
basicAuth: def.production ? false : basicAuth,
|
|
216
225
|
minScale,
|
|
217
226
|
maxScale,
|
package/dist/generate.js
CHANGED
|
@@ -26,6 +26,12 @@ function renderManifest(answers) {
|
|
|
26
26
|
objectStorage: answers.objectStorage,
|
|
27
27
|
basicAuth: answers.basicAuth,
|
|
28
28
|
},
|
|
29
|
+
// Recorded so a resume locks the repository instead of re-running the
|
|
30
|
+
// picker; see hydrateConfigFromManifest.
|
|
31
|
+
github: {
|
|
32
|
+
repoName: answers.github.repoName,
|
|
33
|
+
repoPrivate: answers.github.repoPrivate,
|
|
34
|
+
},
|
|
29
35
|
};
|
|
30
36
|
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
31
37
|
}
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js
|
|
|
7
7
|
import { generateProject, GenerateError, readManifest } from './generate.js';
|
|
8
8
|
import { toolVersion } from './meta.js';
|
|
9
9
|
import { confirmSummary, fillMissing } from './prompts.js';
|
|
10
|
-
import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
|
|
10
|
+
import { isDone, loadState, markDone, STATE_FILE, stepData, stepWarning, } from './state.js';
|
|
11
11
|
import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
|
|
12
12
|
import { ensureStateBucket, validateScalewayCredentials } from './bootstrap/scaleway.js';
|
|
13
13
|
import { bootstrapInfisical, validateInfisical } from './bootstrap/infisical.js';
|
|
@@ -298,8 +298,9 @@ async function runBootstrap(answers, state, options) {
|
|
|
298
298
|
else {
|
|
299
299
|
// All three credentials are checked before anything is created anywhere,
|
|
300
300
|
// so a bad token cannot leave a half-bootstrapped account behind.
|
|
301
|
+
let scalewayWarning;
|
|
301
302
|
await withSpinner('Validating Scaleway, Infisical and GitHub credentials', async () => {
|
|
302
|
-
await validateScalewayCredentials(answers.scaleway);
|
|
303
|
+
scalewayWarning = (await validateScalewayCredentials(answers.scaleway)).warning;
|
|
303
304
|
await validateInfisical(answers.infisical);
|
|
304
305
|
ctx = await createContext(answers.github);
|
|
305
306
|
// A repo with commits would make the push fail after the bucket and the
|
|
@@ -310,14 +311,21 @@ async function runBootstrap(answers, state, options) {
|
|
|
310
311
|
assertRepoUsable(ctx, repoState);
|
|
311
312
|
}
|
|
312
313
|
});
|
|
314
|
+
if (scalewayWarning)
|
|
315
|
+
log.warn(scalewayWarning);
|
|
313
316
|
}
|
|
314
317
|
for (const step of BOOTSTRAP_STEPS) {
|
|
315
318
|
if (isDone(state, step.name)) {
|
|
316
319
|
log.info(step.skipMessage);
|
|
317
320
|
continue;
|
|
318
321
|
}
|
|
322
|
+
// A step that degraded with a warning last run is re-run (idempotent) so
|
|
323
|
+
// the degraded part can heal — e.g. a bucket policy that raced creation.
|
|
324
|
+
if (stepWarning(state, step.name)) {
|
|
325
|
+
log.info('Previous run finished this step with a warning — retrying it.');
|
|
326
|
+
}
|
|
319
327
|
const result = await withSpinner(step.label(answers, ctx), () => step.run(answers, ctx, state));
|
|
320
|
-
markDone(answers.targetDir, state, step.name, result?.data);
|
|
328
|
+
markDone(answers.targetDir, state, step.name, result?.data, result?.warning);
|
|
321
329
|
if (result?.warning)
|
|
322
330
|
log.warn(result.warning);
|
|
323
331
|
}
|
package/dist/prompts.js
CHANGED
|
@@ -55,7 +55,7 @@ export async function fillMissing(partial, options) {
|
|
|
55
55
|
const resuming = manifest !== undefined && manifest.projectName === out.projectName;
|
|
56
56
|
if (manifest && resuming) {
|
|
57
57
|
hydrateConfigFromManifest(out, manifest);
|
|
58
|
-
log.info(`Resuming "${out.projectName}" — region, environments and options are locked to its .keel manifest.`);
|
|
58
|
+
log.info(`Resuming "${out.projectName}" — repository, region, environments and options are locked to its .keel manifest.`);
|
|
59
59
|
}
|
|
60
60
|
// Each block opens with a "┌ <Provider>" section corner and closes with a
|
|
61
61
|
// "└ <Provider> connected — …" line, so every question flows inside its own
|
|
@@ -358,11 +358,16 @@ async function askScaleway(out) {
|
|
|
358
358
|
out.scaleway.organizationId = await text('Scaleway organization ID');
|
|
359
359
|
}
|
|
360
360
|
try {
|
|
361
|
-
await validateScalewayCredentials({
|
|
361
|
+
const { warning } = await validateScalewayCredentials({
|
|
362
362
|
secretKey: out.scaleway.secretKey,
|
|
363
363
|
projectId: out.scaleway.projectId,
|
|
364
364
|
organizationId: out.scaleway.organizationId,
|
|
365
365
|
});
|
|
366
|
+
// e.g. an org policy that would make the first CI apply fail: better a
|
|
367
|
+
// loud heads-up now, while it is a 30-second console fix, than a red
|
|
368
|
+
// pipeline hours after everything here looked green.
|
|
369
|
+
if (warning)
|
|
370
|
+
log.warn(warning);
|
|
366
371
|
p.outro('Scaleway connected — credentials valid and project reachable.');
|
|
367
372
|
return;
|
|
368
373
|
}
|
package/dist/state.js
CHANGED
|
@@ -18,10 +18,15 @@ export function saveState(targetDir, state) {
|
|
|
18
18
|
writeFileSync(join(targetDir, STATE_FILE), `${JSON.stringify(state, null, 2)}\n`);
|
|
19
19
|
}
|
|
20
20
|
export function isDone(state, step) {
|
|
21
|
-
|
|
21
|
+
const record = state.steps[step];
|
|
22
|
+
return record !== undefined && record.warning === undefined;
|
|
22
23
|
}
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
/** The warning a previous run recorded for this step, if it degraded. */
|
|
25
|
+
export function stepWarning(state, step) {
|
|
26
|
+
return state.steps[step]?.warning;
|
|
27
|
+
}
|
|
28
|
+
export function markDone(targetDir, state, step, data, warning) {
|
|
29
|
+
state.steps[step] = { ...(data ? { data } : {}), ...(warning ? { warning } : {}) };
|
|
25
30
|
saveState(targetDir, state);
|
|
26
31
|
}
|
|
27
32
|
export function stepData(state, step, key) {
|
package/dist/ui.js
CHANGED
|
@@ -59,7 +59,7 @@ export function renderSummary(answers, dryRun) {
|
|
|
59
59
|
for (const env of answers.environments) {
|
|
60
60
|
const flags = [
|
|
61
61
|
`scale ${env.minScale}-${env.maxScale}`,
|
|
62
|
-
env.
|
|
62
|
+
env.production ? 'deploys on version tag' : 'deploys on merge to main',
|
|
63
63
|
env.basicAuth ? 'basic auth' : null,
|
|
64
64
|
]
|
|
65
65
|
.filter(Boolean)
|
package/package.json
CHANGED
package/templates/README.md
CHANGED
|
@@ -209,6 +209,10 @@ Avoid local applies: they race against CI on the same state.
|
|
|
209
209
|
match a Scaleway key with Object Storage access.
|
|
210
210
|
- **Plan fails reading Infisical secrets**: check `INFISICAL_PROJECT_ID` and
|
|
211
211
|
that the machine identity has access to the project and every environment.
|
|
212
|
+
If the error mentions a **usage limit**, the machine identity's client
|
|
213
|
+
secret was created with a maximum number of uses and it is spent: create a
|
|
214
|
+
new client secret with unlimited uses (the pipeline logs in on every
|
|
215
|
+
plan/apply) and update the `INFISICAL_CLIENT_SECRET` Actions secret.
|
|
212
216
|
- **Apply green but no container**: expected until `container_image` is set.
|
|
213
217
|
- **App can't reach the database**: `DATABASE_URL` is only synced after an
|
|
214
218
|
apply; check the value in Infisical for that environment. It must contain
|