@catladder/cli 0.0.0-next-71c49b28 → 0.0.0-next-5dc1f907
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/dist/bundles/catenv/index.js +75 -12
- package/dist/bundles/cli/index.js +847 -51
- package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.d.ts +22 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js +75 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +15 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +59 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +13 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +120 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.d.ts +10 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js +100 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.d.ts +13 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js +79 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.d.ts +8 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js +44 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/index.d.ts +14 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/index.js +48 -0
- package/dist/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -0
- package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +4 -1
- package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
- package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.d.ts +21 -0
- package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js +31 -11
- package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js.map +1 -1
- package/dist/cli/src/commands/index.d.ts +1 -0
- package/dist/cli/src/commands/index.js +8 -1
- package/dist/cli/src/commands/index.js.map +1 -1
- package/dist/cli/src/commands/project/commandDoctor.d.ts +8 -0
- package/dist/cli/src/commands/project/commandDoctor.js +25 -0
- package/dist/cli/src/commands/project/commandDoctor.js.map +1 -0
- package/dist/cli/src/gcloud/artifactsRegistry.d.ts +1 -0
- package/dist/cli/src/gcloud/artifactsRegistry.js +3 -2
- package/dist/cli/src/gcloud/artifactsRegistry.js.map +1 -1
- package/dist/cli/src/gcloud/serviceAccountNames.d.ts +28 -0
- package/dist/cli/src/gcloud/serviceAccountNames.js +51 -0
- package/dist/cli/src/gcloud/serviceAccountNames.js.map +1 -0
- package/dist/cli/src/gcloud/serviceAccounts.js +6 -27
- package/dist/cli/src/gcloud/serviceAccounts.js.map +1 -1
- package/dist/cli/src/utils/concurrency.d.ts +7 -0
- package/dist/cli/src/utils/concurrency.js +27 -0
- package/dist/cli/src/utils/concurrency.js.map +1 -0
- package/dist/cli/src/utils/github.d.ts +10 -0
- package/dist/cli/src/utils/github.js +26 -1
- package/dist/cli/src/utils/github.js.map +1 -1
- package/dist/cli/src/utils/gitlab.d.ts +6 -0
- package/dist/cli/src/utils/gitlab.js +19 -1
- package/dist/cli/src/utils/gitlab.js.map +1 -1
- package/dist/pipeline/src/backends/github/createGithubJobs.js +24 -2
- package/dist/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
- package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js +8 -7
- package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js.map +1 -1
- package/dist/pipeline/src/build/cache/createJobCache.js +8 -0
- package/dist/pipeline/src/build/cache/createJobCache.js.map +1 -1
- package/dist/pipeline/src/build/node/cache.js +15 -1
- package/dist/pipeline/src/build/node/cache.js.map +1 -1
- package/dist/pipeline/src/build/types.d.ts +19 -0
- package/dist/pipeline/src/build/types.js.map +1 -1
- package/dist/pipeline/src/constants.js +1 -1
- package/dist/pipeline/src/types/jobs.d.ts +6 -0
- package/dist/pipeline/src/types/jobs.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/apps/cli/commands/project/doctor/DoctorReport.ts +72 -0
- package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +77 -0
- package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +148 -0
- package/src/apps/cli/commands/project/doctor/checkGithub.ts +156 -0
- package/src/apps/cli/commands/project/doctor/checkGitlab.ts +133 -0
- package/src/apps/cli/commands/project/doctor/checkStore.ts +43 -0
- package/src/apps/cli/commands/project/doctor/index.ts +59 -0
- package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +9 -2
- package/src/apps/cli/commands/project/setup/setupCloudRun.ts +42 -21
- package/src/commands/index.ts +1 -0
- package/src/commands/project/commandDoctor.ts +21 -0
- package/src/gcloud/__tests__/serviceAccountNames.test.ts +63 -0
- package/src/gcloud/artifactsRegistry.ts +3 -1
- package/src/gcloud/serviceAccountNames.ts +65 -0
- package/src/gcloud/serviceAccounts.ts +6 -38
- package/src/utils/concurrency.ts +25 -0
- package/src/utils/github.ts +48 -0
- package/src/utils/gitlab.ts +24 -0
- package/tsconfig.json +2 -1
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import type { IO } from "../../../../../core/types";
|
|
2
2
|
import { add, format } from "date-fns";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
doGitlabRequest,
|
|
5
|
+
doGitlabRequestAllPages,
|
|
6
|
+
getProjectInfo,
|
|
7
|
+
} from "../../../../../utils/gitlab";
|
|
4
8
|
|
|
5
9
|
const TOKEN_NAME = "semantic-release";
|
|
6
10
|
|
|
7
11
|
export const setupAccessTokens = async (instance: IO) => {
|
|
8
12
|
const { id: projectId } = await getProjectInfo(instance);
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
// all pages: rotated tokens accumulate one (inactive) entry per
|
|
15
|
+
// rotation, so the active one quickly moves past the first page —
|
|
16
|
+
// missing it here created a brand-new duplicate token on every setup
|
|
17
|
+
const projectTokens = await doGitlabRequestAllPages(
|
|
11
18
|
instance,
|
|
12
19
|
`projects/${projectId}/access_tokens`,
|
|
13
20
|
);
|
|
@@ -11,6 +11,45 @@ import { fetchGcloudProjectNumber } from "../../../../../gcloud/getProjectNumber
|
|
|
11
11
|
import { upsertGcloudServiceAccountAndSaveSecret } from "../../../../../gcloud/serviceAccounts";
|
|
12
12
|
import { updateCatladderStore } from "../../../../../store/updateCatladderStore";
|
|
13
13
|
|
|
14
|
+
export const CLOUD_RUN_DEPLOY_SA_NAME = "cl-d";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* the gcloud services the given deploy config requires.
|
|
18
|
+
*
|
|
19
|
+
* Shared with `project doctor`: setup enables them, doctor compares
|
|
20
|
+
* them against what is actually enabled — config edits after setup
|
|
21
|
+
* (e.g. adding `cloudSql`) would otherwise drift silently.
|
|
22
|
+
*/
|
|
23
|
+
export const getCloudRunRequiredServices = (config: {
|
|
24
|
+
cloudSql?: unknown;
|
|
25
|
+
}): string[] => [
|
|
26
|
+
"run.googleapis.com",
|
|
27
|
+
"artifactregistry.googleapis.com",
|
|
28
|
+
"cloudscheduler.googleapis.com", // for scheduling jobs
|
|
29
|
+
"cloudresourcemanager.googleapis.com", // only required to get google cloud project number
|
|
30
|
+
...(config.cloudSql
|
|
31
|
+
? ["sqladmin.googleapis.com", "sql-component.googleapis.com"]
|
|
32
|
+
: []),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* the IAM roles the deploy service account needs for the given deploy
|
|
37
|
+
* config. Shared with `project doctor` (same rationale as
|
|
38
|
+
* {@link getCloudRunRequiredServices}) — the motivating case: a
|
|
39
|
+
* component gets `cloudSql` after its setup ran, deploys keep working,
|
|
40
|
+
* and only the review teardown hits the missing `cloudsql.admin`.
|
|
41
|
+
*/
|
|
42
|
+
export const getCloudRunDeployRoles = (config: {
|
|
43
|
+
cloudSql?: unknown;
|
|
44
|
+
}): string[] => [
|
|
45
|
+
// TODO: run.admin is a bit much, would be better to follow https://stackoverflow.com/questions/55788714/deploying-to-cloud-run-with-a-custom-service-account-failed-with-iam-serviceacco
|
|
46
|
+
"roles/artifactregistry.repoAdmin",
|
|
47
|
+
"roles/run.admin",
|
|
48
|
+
"roles/iam.serviceAccountUser",
|
|
49
|
+
"roles/cloudscheduler.admin", // for scheduling
|
|
50
|
+
...(config.cloudSql ? ["roles/cloudsql.admin"] : []),
|
|
51
|
+
];
|
|
52
|
+
|
|
14
53
|
export const setupCloudRun = async (
|
|
15
54
|
instance: IO,
|
|
16
55
|
context: ComponentContext,
|
|
@@ -24,18 +63,7 @@ export const setupCloudRun = async (
|
|
|
24
63
|
// enable services
|
|
25
64
|
|
|
26
65
|
instance.log("enable required services...");
|
|
27
|
-
await enableGCloudServices(
|
|
28
|
-
[
|
|
29
|
-
"run.googleapis.com",
|
|
30
|
-
"artifactregistry.googleapis.com",
|
|
31
|
-
"cloudscheduler.googleapis.com", // for scheduling jobs
|
|
32
|
-
"cloudresourcemanager.googleapis.com", // only required to get google cloud project number
|
|
33
|
-
...(config.cloudSql
|
|
34
|
-
? ["sqladmin.googleapis.com", "sql-component.googleapis.com"]
|
|
35
|
-
: []),
|
|
36
|
-
],
|
|
37
|
-
config,
|
|
38
|
-
);
|
|
66
|
+
await enableGCloudServices(getCloudRunRequiredServices(config), config);
|
|
39
67
|
instance.log("upsert artifacts registry...");
|
|
40
68
|
await upsertGcloudArtifactsRegistry(config);
|
|
41
69
|
|
|
@@ -47,15 +75,8 @@ export const setupCloudRun = async (
|
|
|
47
75
|
projectId: config.projectId,
|
|
48
76
|
displayName: "Catladder gcloud deploy",
|
|
49
77
|
description: "This service account deploys to google cloud",
|
|
50
|
-
name:
|
|
51
|
-
|
|
52
|
-
roles: [
|
|
53
|
-
"roles/artifactregistry.repoAdmin",
|
|
54
|
-
"roles/run.admin",
|
|
55
|
-
"roles/iam.serviceAccountUser",
|
|
56
|
-
"roles/cloudscheduler.admin", // for scheduling
|
|
57
|
-
...(config.cloudSql ? ["roles/cloudsql.admin"] : []),
|
|
58
|
-
],
|
|
78
|
+
name: CLOUD_RUN_DEPLOY_SA_NAME,
|
|
79
|
+
roles: getCloudRunDeployRoles(config),
|
|
59
80
|
},
|
|
60
81
|
GCLOUD_DEPLOY_CREDENTIALS_KEY,
|
|
61
82
|
);
|
package/src/commands/index.ts
CHANGED
|
@@ -21,6 +21,7 @@ export { commandTriggerCronjobGeneral } from "./project/k8s/commandTriggerCronjo
|
|
|
21
21
|
export { commandRenewToken } from "./project/commandRenewToken";
|
|
22
22
|
export { commandGetMyTotalWorktime } from "./project/commandGetMyTotalWorktime";
|
|
23
23
|
export { commandSetup } from "./project/commandSetup";
|
|
24
|
+
export { commandDoctor } from "./project/commandDoctor";
|
|
24
25
|
export { commandCiJobOpen, commandCiJobLog } from "./project/commandGitlabCi";
|
|
25
26
|
export { commandProjectRestoreDb } from "./project/commandProjectRestoreDb";
|
|
26
27
|
export { commandSecurityEvaluate } from "./project/commandSecurityEvaluate";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { doctorProject } from "../../apps/cli/commands/project/doctor";
|
|
2
|
+
import { defineCommand } from "../../core/defineCommand";
|
|
3
|
+
|
|
4
|
+
export const commandDoctor = defineCommand({
|
|
5
|
+
name: "project doctor",
|
|
6
|
+
description:
|
|
7
|
+
"Checks for drift between the config and the provisioned infrastructure (read-only): service accounts, IAM roles, gitlab tokens, github secrets, store entries.",
|
|
8
|
+
group: "project",
|
|
9
|
+
inputs: {
|
|
10
|
+
component: {
|
|
11
|
+
type: "string",
|
|
12
|
+
message: "component name",
|
|
13
|
+
positional: true,
|
|
14
|
+
required: false,
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
execute: async (ctx) => {
|
|
18
|
+
const component = await ctx.get("component");
|
|
19
|
+
await doctorProject(ctx, component);
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getGcloudServiceAccountNames } from "../serviceAccountNames";
|
|
3
|
+
|
|
4
|
+
// the naming is shared between `project setup` (creates accounts) and
|
|
5
|
+
// `project doctor` (computes the expected identifier) — these tests pin
|
|
6
|
+
// it down so a change never silently makes doctor look for accounts
|
|
7
|
+
// under a different name than setup created them
|
|
8
|
+
|
|
9
|
+
const account = { name: "cl-d", projectId: "some-project" };
|
|
10
|
+
|
|
11
|
+
describe("getGcloudServiceAccountNames", () => {
|
|
12
|
+
it("keeps all parts verbatim when they fit into 30 chars", () => {
|
|
13
|
+
const { fullName, fullIdentifier } = getGcloudServiceAccountNames(
|
|
14
|
+
{
|
|
15
|
+
env: "dev",
|
|
16
|
+
name: "www",
|
|
17
|
+
fullConfig: { customerName: "acme", appName: "shop" },
|
|
18
|
+
},
|
|
19
|
+
account,
|
|
20
|
+
);
|
|
21
|
+
expect(fullName).toBe("cl-d-acme-shop-dev-www");
|
|
22
|
+
expect(fullIdentifier).toBe(
|
|
23
|
+
"cl-d-acme-shop-dev-www@some-project.iam.gserviceaccount.com",
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("hashes the customer/app middle part when the name would exceed 30 chars", () => {
|
|
28
|
+
const { fullName } = getGcloudServiceAccountNames(
|
|
29
|
+
{
|
|
30
|
+
env: "review",
|
|
31
|
+
name: "outlet-stats",
|
|
32
|
+
fullConfig: { customerName: "wea", appName: "food2050" },
|
|
33
|
+
},
|
|
34
|
+
account,
|
|
35
|
+
);
|
|
36
|
+
expect(fullName).toMatch(/^cl-d-[0-9a-f]{5}-review-outlet-stats$/);
|
|
37
|
+
expect(fullName.length).toBe(30);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("hashes the env/component suffix too when a 1-char middle still does not fit", () => {
|
|
41
|
+
const { fullName } = getGcloudServiceAccountNames(
|
|
42
|
+
{
|
|
43
|
+
env: "review",
|
|
44
|
+
name: "a-component-with-a-very-long-name",
|
|
45
|
+
fullConfig: { customerName: "customer", appName: "app" },
|
|
46
|
+
},
|
|
47
|
+
account,
|
|
48
|
+
);
|
|
49
|
+
expect(fullName).toMatch(/^cl-d-[0-9a-f]-[0-9a-f]{23}$/);
|
|
50
|
+
expect(fullName.length).toBe(30);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("is deterministic", () => {
|
|
54
|
+
const context = {
|
|
55
|
+
env: "review",
|
|
56
|
+
name: "outlet-stats",
|
|
57
|
+
fullConfig: { customerName: "wea", appName: "food2050" },
|
|
58
|
+
};
|
|
59
|
+
expect(getGcloudServiceAccountNames(context, account)).toEqual(
|
|
60
|
+
getGcloudServiceAccountNames(context, account),
|
|
61
|
+
);
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import type { Gcloudregion } from "@catladder/pipeline";
|
|
2
2
|
import { exec } from "child-process-promise";
|
|
3
3
|
|
|
4
|
+
export const CATLADDER_REGISTRY_NAME = "catladder-deploy";
|
|
5
|
+
|
|
4
6
|
export const upsertGcloudArtifactsRegistry = async (config: {
|
|
5
7
|
projectId: string;
|
|
6
8
|
region: Gcloudregion;
|
|
7
9
|
}) => {
|
|
8
10
|
try {
|
|
9
11
|
await exec(
|
|
10
|
-
`gcloud artifacts repositories create
|
|
12
|
+
`gcloud artifacts repositories create ${CATLADDER_REGISTRY_NAME} --project="${config.projectId}" --repository-format=docker --location=${config.region}`,
|
|
11
13
|
);
|
|
12
14
|
} catch (e) {
|
|
13
15
|
// probably already exists
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { createHmac } from "crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* the parts of a component context the naming depends on — structural
|
|
5
|
+
* subset of `ComponentContext` so the naming stays a pure function
|
|
6
|
+
*/
|
|
7
|
+
export type ServiceAccountNamingContext = {
|
|
8
|
+
env: string;
|
|
9
|
+
name: string;
|
|
10
|
+
fullConfig: { customerName: string; appName: string };
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* the deterministic gcloud service account naming used by
|
|
15
|
+
* `project setup`. Extracted so `project doctor` can compute the
|
|
16
|
+
* expected identifier of an account without creating anything.
|
|
17
|
+
*
|
|
18
|
+
* gcloud limits account ids to 30 chars: the customer/app middle part
|
|
19
|
+
* (and, if even that is not enough, the env/component suffix) is
|
|
20
|
+
* replaced by a hash when the full name would not fit.
|
|
21
|
+
*/
|
|
22
|
+
export const getGcloudServiceAccountNames = (
|
|
23
|
+
context: ServiceAccountNamingContext,
|
|
24
|
+
account: { name: string; projectId: string },
|
|
25
|
+
): { fullName: string; fullIdentifier: string } => {
|
|
26
|
+
const { projectId, name } = account;
|
|
27
|
+
|
|
28
|
+
// name has limit of 30
|
|
29
|
+
const namePrefix = `${name}`;
|
|
30
|
+
const nameSuffixRaw = `${context.env}-${context.name}`;
|
|
31
|
+
const nameMiddleRaw = `${context.fullConfig.customerName}-${context.fullConfig.appName}`;
|
|
32
|
+
const MAX_LENGTH = 30;
|
|
33
|
+
const NUM_SEPARATORS = 2;
|
|
34
|
+
|
|
35
|
+
// we want to first hash middle, then suffix
|
|
36
|
+
// if for middle we have at least 1 char left, its ok, so we don't hash nameSuffix, otherwise we need to hash that as well
|
|
37
|
+
|
|
38
|
+
const middleMaxLength =
|
|
39
|
+
MAX_LENGTH - namePrefix.length - nameSuffixRaw.length - NUM_SEPARATORS;
|
|
40
|
+
|
|
41
|
+
let nameMiddle: string;
|
|
42
|
+
let nameSuffix: string;
|
|
43
|
+
if (middleMaxLength < 1) {
|
|
44
|
+
nameMiddle = hashIfNessecary(nameMiddleRaw, 1);
|
|
45
|
+
nameSuffix = hashIfNessecary(
|
|
46
|
+
nameSuffixRaw,
|
|
47
|
+
MAX_LENGTH - namePrefix.length - 1 - NUM_SEPARATORS,
|
|
48
|
+
);
|
|
49
|
+
} else {
|
|
50
|
+
nameMiddle = hashIfNessecary(nameMiddleRaw, middleMaxLength);
|
|
51
|
+
nameSuffix = nameSuffixRaw;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fullName = `${namePrefix}-${nameMiddle}-${nameSuffix}`;
|
|
55
|
+
const fullIdentifier = `${fullName}@${projectId}.iam.gserviceaccount.com`;
|
|
56
|
+
|
|
57
|
+
return { fullName, fullIdentifier };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const hashIfNessecary = (str: string, maxLength: number) =>
|
|
61
|
+
str.length > maxLength ? hash(str, maxLength) : str;
|
|
62
|
+
|
|
63
|
+
const hash = (str: string, length: number) => {
|
|
64
|
+
return createHmac("sha256", str).digest("hex").substring(0, length);
|
|
65
|
+
};
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { ComponentContext } from "@catladder/pipeline";
|
|
2
2
|
|
|
3
3
|
import { exec } from "child-process-promise";
|
|
4
|
-
import { createHmac } from "crypto";
|
|
5
4
|
import type { IO } from "../core/types";
|
|
6
5
|
import { upsertAllVariables } from "../utils/gitlab";
|
|
6
|
+
import { getGcloudServiceAccountNames } from "./serviceAccountNames";
|
|
7
7
|
|
|
8
8
|
export const accountExists = async (fullIdentifier: string) => {
|
|
9
9
|
try {
|
|
@@ -25,40 +25,15 @@ const upsertGcloudServiceAccount = async (
|
|
|
25
25
|
context: ComponentContext,
|
|
26
26
|
account: ServiceAccount,
|
|
27
27
|
): Promise<string> => {
|
|
28
|
-
const { projectId,
|
|
28
|
+
const { projectId, displayName, roles, description } = account;
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const MAX_LENGTH = 30;
|
|
35
|
-
const NUM_SEPARATORS = 2;
|
|
36
|
-
|
|
37
|
-
// we want to first hash middle, then suffix
|
|
38
|
-
// if for middle we have at least 1 char left, its ok, so we don't hash nameSuffix, otherwise we need to hash that as well
|
|
39
|
-
|
|
40
|
-
const middleMaxLength =
|
|
41
|
-
MAX_LENGTH - namePrefix.length - nameSuffixRaw.length - NUM_SEPARATORS;
|
|
42
|
-
|
|
43
|
-
let nameMiddle: string;
|
|
44
|
-
let nameSuffix: string;
|
|
45
|
-
if (middleMaxLength < 1) {
|
|
46
|
-
nameMiddle = hashIfNessecary(nameMiddleRaw, 1);
|
|
47
|
-
nameSuffix = hashIfNessecary(
|
|
48
|
-
nameSuffixRaw,
|
|
49
|
-
MAX_LENGTH - namePrefix.length - 1 - NUM_SEPARATORS,
|
|
50
|
-
);
|
|
51
|
-
} else {
|
|
52
|
-
nameMiddle = hashIfNessecary(nameMiddleRaw, middleMaxLength);
|
|
53
|
-
nameSuffix = nameSuffixRaw;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
const fullName = `${namePrefix}-${nameMiddle}-${nameSuffix}`;
|
|
30
|
+
const { fullName, fullIdentifier } = getGcloudServiceAccountNames(
|
|
31
|
+
context,
|
|
32
|
+
account,
|
|
33
|
+
);
|
|
57
34
|
|
|
58
35
|
const fullDisplayName = `${context.fullConfig.customerName}-${context.fullConfig.appName} ${context.env}:${context.name} | ${displayName}`;
|
|
59
36
|
|
|
60
|
-
const fullIdentifier = `${fullName}@${projectId}.iam.gserviceaccount.com`;
|
|
61
|
-
|
|
62
37
|
const existing = await accountExists(fullIdentifier);
|
|
63
38
|
|
|
64
39
|
if (!existing) {
|
|
@@ -111,10 +86,3 @@ export const upsertGcloudServiceAccountAndSaveSecret = async (
|
|
|
111
86
|
);
|
|
112
87
|
instance.log("done!");
|
|
113
88
|
};
|
|
114
|
-
|
|
115
|
-
const hashIfNessecary = (str: string, maxLength: number) =>
|
|
116
|
-
str.length > maxLength ? hash(str, maxLength) : str;
|
|
117
|
-
|
|
118
|
-
const hash = (str: string, length: number) => {
|
|
119
|
-
return createHmac("sha256", str).digest("hex").substring(0, length);
|
|
120
|
-
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* maps over items with at most `limit` calls in flight, preserving the
|
|
3
|
+
* input order in the result (unlike reporting-as-you-go, this lets
|
|
4
|
+
* callers run slow per-item lookups in parallel and still log the
|
|
5
|
+
* outcomes in a stable order)
|
|
6
|
+
*/
|
|
7
|
+
export const mapWithConcurrency = async <T, R>(
|
|
8
|
+
items: T[],
|
|
9
|
+
limit: number,
|
|
10
|
+
fn: (item: T, index: number) => Promise<R>,
|
|
11
|
+
): Promise<R[]> => {
|
|
12
|
+
const results = new Array<R>(items.length);
|
|
13
|
+
let next = 0;
|
|
14
|
+
const workers = Array.from(
|
|
15
|
+
{ length: Math.max(1, Math.min(limit, items.length)) },
|
|
16
|
+
async () => {
|
|
17
|
+
while (next < items.length) {
|
|
18
|
+
const index = next++;
|
|
19
|
+
results[index] = await fn(items[index], index);
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
);
|
|
23
|
+
await Promise.all(workers);
|
|
24
|
+
return results;
|
|
25
|
+
};
|
package/src/utils/github.ts
CHANGED
|
@@ -34,6 +34,54 @@ export const getGithubRepoFromRemote = async (
|
|
|
34
34
|
}
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* runs a paginated `gh api` query and returns the jq-extracted lines
|
|
39
|
+
* (one value per line, e.g. names)
|
|
40
|
+
*/
|
|
41
|
+
const ghApiLines = async (path: string, jq: string): Promise<string[]> => {
|
|
42
|
+
const { stdout } = await execFile(
|
|
43
|
+
"gh",
|
|
44
|
+
["api", "--paginate", path, "--jq", jq],
|
|
45
|
+
{ maxBuffer: 10 * 1024 * 1024 },
|
|
46
|
+
);
|
|
47
|
+
return stdout
|
|
48
|
+
.split("\n")
|
|
49
|
+
.map((line) => line.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** names of all deployment environments of the repository */
|
|
54
|
+
export const listGithubEnvironments = (repo: string): Promise<string[]> =>
|
|
55
|
+
ghApiLines(`repos/${repo}/environments?per_page=100`, ".environments[].name");
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* names of the actions secrets — of an environment, or of the repo
|
|
59
|
+
* level when no environment is given. Github secrets are write-only:
|
|
60
|
+
* names are all that can ever be inspected.
|
|
61
|
+
*/
|
|
62
|
+
export const listGithubSecretNames = (
|
|
63
|
+
repo: string,
|
|
64
|
+
environment?: string,
|
|
65
|
+
): Promise<string[]> =>
|
|
66
|
+
ghApiLines(
|
|
67
|
+
environment
|
|
68
|
+
? `repos/${repo}/environments/${encodeURIComponent(environment)}/secrets?per_page=100`
|
|
69
|
+
: `repos/${repo}/actions/secrets?per_page=100`,
|
|
70
|
+
".secrets[].name",
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
/** names of the actions variables (environment or repo level) */
|
|
74
|
+
export const listGithubVariableNames = (
|
|
75
|
+
repo: string,
|
|
76
|
+
environment?: string,
|
|
77
|
+
): Promise<string[]> =>
|
|
78
|
+
ghApiLines(
|
|
79
|
+
environment
|
|
80
|
+
? `repos/${repo}/environments/${encodeURIComponent(environment)}/variables?per_page=100`
|
|
81
|
+
: `repos/${repo}/actions/variables?per_page=100`,
|
|
82
|
+
".variables[].name",
|
|
83
|
+
);
|
|
84
|
+
|
|
37
85
|
const ghSetViaStdin = (
|
|
38
86
|
kind: "secret" | "variable",
|
|
39
87
|
repo: string,
|
package/src/utils/gitlab.ts
CHANGED
|
@@ -90,6 +90,30 @@ export const doGitlabRequest = async <T = any>(
|
|
|
90
90
|
);
|
|
91
91
|
};
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* GET a list endpoint across ALL pages (gitlab returns 20 items per
|
|
95
|
+
* page by default — long-lived projects easily exceed that, e.g.
|
|
96
|
+
* rotated access tokens accumulate one entry per rotation)
|
|
97
|
+
*/
|
|
98
|
+
export const doGitlabRequestAllPages = async <T = any>(
|
|
99
|
+
io: IO | null,
|
|
100
|
+
path: string,
|
|
101
|
+
): Promise<T[]> => {
|
|
102
|
+
const PER_PAGE = 100;
|
|
103
|
+
const separator = path.includes("?") ? "&" : "?";
|
|
104
|
+
const all: T[] = [];
|
|
105
|
+
for (let page = 1; ; page++) {
|
|
106
|
+
const items = await doGitlabRequest<T[]>(
|
|
107
|
+
io,
|
|
108
|
+
`${path}${separator}per_page=${PER_PAGE}&page=${page}`,
|
|
109
|
+
);
|
|
110
|
+
if (!items?.length) break;
|
|
111
|
+
all.push(...items);
|
|
112
|
+
if (items.length < PER_PAGE) break;
|
|
113
|
+
}
|
|
114
|
+
return all;
|
|
115
|
+
};
|
|
116
|
+
|
|
93
117
|
export const getProjectInfo = async (
|
|
94
118
|
io: IO | null,
|
|
95
119
|
): Promise<{ id: string; web_url: string }> => {
|