@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.
Files changed (85) hide show
  1. package/dist/bundles/catenv/index.js +75 -12
  2. package/dist/bundles/cli/index.js +847 -51
  3. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.d.ts +22 -0
  4. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js +75 -0
  5. package/dist/cli/src/apps/cli/commands/project/doctor/DoctorReport.js.map +1 -0
  6. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.d.ts +15 -0
  7. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js +59 -0
  8. package/dist/cli/src/apps/cli/commands/project/doctor/GcloudProjectInspector.js.map +1 -0
  9. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.d.ts +13 -0
  10. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js +120 -0
  11. package/dist/cli/src/apps/cli/commands/project/doctor/checkCloudRun.js.map +1 -0
  12. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.d.ts +10 -0
  13. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js +100 -0
  14. package/dist/cli/src/apps/cli/commands/project/doctor/checkGithub.js.map +1 -0
  15. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.d.ts +13 -0
  16. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js +79 -0
  17. package/dist/cli/src/apps/cli/commands/project/doctor/checkGitlab.js.map +1 -0
  18. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.d.ts +8 -0
  19. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js +44 -0
  20. package/dist/cli/src/apps/cli/commands/project/doctor/checkStore.js.map +1 -0
  21. package/dist/cli/src/apps/cli/commands/project/doctor/index.d.ts +14 -0
  22. package/dist/cli/src/apps/cli/commands/project/doctor/index.js +48 -0
  23. package/dist/cli/src/apps/cli/commands/project/doctor/index.js.map +1 -0
  24. package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js +4 -1
  25. package/dist/cli/src/apps/cli/commands/project/setup/setupAccessTokens.js.map +1 -1
  26. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.d.ts +21 -0
  27. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js +31 -11
  28. package/dist/cli/src/apps/cli/commands/project/setup/setupCloudRun.js.map +1 -1
  29. package/dist/cli/src/commands/index.d.ts +1 -0
  30. package/dist/cli/src/commands/index.js +8 -1
  31. package/dist/cli/src/commands/index.js.map +1 -1
  32. package/dist/cli/src/commands/project/commandDoctor.d.ts +8 -0
  33. package/dist/cli/src/commands/project/commandDoctor.js +25 -0
  34. package/dist/cli/src/commands/project/commandDoctor.js.map +1 -0
  35. package/dist/cli/src/gcloud/artifactsRegistry.d.ts +1 -0
  36. package/dist/cli/src/gcloud/artifactsRegistry.js +3 -2
  37. package/dist/cli/src/gcloud/artifactsRegistry.js.map +1 -1
  38. package/dist/cli/src/gcloud/serviceAccountNames.d.ts +28 -0
  39. package/dist/cli/src/gcloud/serviceAccountNames.js +51 -0
  40. package/dist/cli/src/gcloud/serviceAccountNames.js.map +1 -0
  41. package/dist/cli/src/gcloud/serviceAccounts.js +6 -27
  42. package/dist/cli/src/gcloud/serviceAccounts.js.map +1 -1
  43. package/dist/cli/src/utils/concurrency.d.ts +7 -0
  44. package/dist/cli/src/utils/concurrency.js +27 -0
  45. package/dist/cli/src/utils/concurrency.js.map +1 -0
  46. package/dist/cli/src/utils/github.d.ts +10 -0
  47. package/dist/cli/src/utils/github.js +26 -1
  48. package/dist/cli/src/utils/github.js.map +1 -1
  49. package/dist/cli/src/utils/gitlab.d.ts +6 -0
  50. package/dist/cli/src/utils/gitlab.js +19 -1
  51. package/dist/cli/src/utils/gitlab.js.map +1 -1
  52. package/dist/pipeline/src/backends/github/createGithubJobs.js +24 -2
  53. package/dist/pipeline/src/backends/github/createGithubJobs.js.map +1 -1
  54. package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js +8 -7
  55. package/dist/pipeline/src/backends/gitlab/createGitlabJobs.js.map +1 -1
  56. package/dist/pipeline/src/build/cache/createJobCache.js +8 -0
  57. package/dist/pipeline/src/build/cache/createJobCache.js.map +1 -1
  58. package/dist/pipeline/src/build/node/cache.js +15 -1
  59. package/dist/pipeline/src/build/node/cache.js.map +1 -1
  60. package/dist/pipeline/src/build/types.d.ts +19 -0
  61. package/dist/pipeline/src/build/types.js.map +1 -1
  62. package/dist/pipeline/src/constants.js +1 -1
  63. package/dist/pipeline/src/types/jobs.d.ts +6 -0
  64. package/dist/pipeline/src/types/jobs.js.map +1 -1
  65. package/dist/tsconfig.tsbuildinfo +1 -1
  66. package/package.json +1 -1
  67. package/src/apps/cli/commands/project/doctor/DoctorReport.ts +72 -0
  68. package/src/apps/cli/commands/project/doctor/GcloudProjectInspector.ts +77 -0
  69. package/src/apps/cli/commands/project/doctor/checkCloudRun.ts +148 -0
  70. package/src/apps/cli/commands/project/doctor/checkGithub.ts +156 -0
  71. package/src/apps/cli/commands/project/doctor/checkGitlab.ts +133 -0
  72. package/src/apps/cli/commands/project/doctor/checkStore.ts +43 -0
  73. package/src/apps/cli/commands/project/doctor/index.ts +59 -0
  74. package/src/apps/cli/commands/project/setup/setupAccessTokens.ts +9 -2
  75. package/src/apps/cli/commands/project/setup/setupCloudRun.ts +42 -21
  76. package/src/commands/index.ts +1 -0
  77. package/src/commands/project/commandDoctor.ts +21 -0
  78. package/src/gcloud/__tests__/serviceAccountNames.test.ts +63 -0
  79. package/src/gcloud/artifactsRegistry.ts +3 -1
  80. package/src/gcloud/serviceAccountNames.ts +65 -0
  81. package/src/gcloud/serviceAccounts.ts +6 -38
  82. package/src/utils/concurrency.ts +25 -0
  83. package/src/utils/github.ts +48 -0
  84. package/src/utils/gitlab.ts +24 -0
  85. package/tsconfig.json +2 -1
package/package.json CHANGED
@@ -53,7 +53,7 @@
53
53
  }
54
54
  ],
55
55
  "license": "MIT",
56
- "version": "0.0.0-next-71c49b28",
56
+ "version": "0.0.0-next-5dc1f907",
57
57
  "scripts": {
58
58
  "lint": "eslint \"src/**/*.ts\"",
59
59
  "lint:fix": "eslint \"src/**/*.ts\" --fix",
@@ -0,0 +1,72 @@
1
+ import type { IO } from "../../../../../core/types";
2
+
3
+ type Finding = {
4
+ section: string;
5
+ message: string;
6
+ remediation?: string;
7
+ };
8
+
9
+ /**
10
+ * collects the results of all doctor checks: each check logs
11
+ * immediately (progressive output like `project setup`) and problems
12
+ * are repeated in the final summary together with the command that
13
+ * heals them. The report itself never mutates anything — doctor
14
+ * diagnoses, setup heals.
15
+ */
16
+ export class DoctorReport {
17
+ private passed = 0;
18
+ private readonly warnings: Finding[] = [];
19
+ private readonly problems: Finding[] = [];
20
+ private currentSection = "";
21
+
22
+ constructor(private readonly io: IO) {}
23
+
24
+ section(title: string) {
25
+ this.currentSection = title;
26
+ this.io.log("");
27
+ this.io.log("🩺 " + title);
28
+ }
29
+
30
+ ok(message: string) {
31
+ this.passed++;
32
+ this.io.log(" ✅ " + message);
33
+ }
34
+
35
+ warn(message: string, remediation?: string) {
36
+ this.warnings.push({ section: this.currentSection, message, remediation });
37
+ this.io.log(" ⚠️ " + message);
38
+ if (remediation) this.io.log(" ↳ " + remediation);
39
+ }
40
+
41
+ fail(message: string, remediation?: string) {
42
+ this.problems.push({ section: this.currentSection, message, remediation });
43
+ this.io.log(" ❌ " + message);
44
+ if (remediation) this.io.log(" ↳ " + remediation);
45
+ }
46
+
47
+ get hasProblems(): boolean {
48
+ return this.problems.length > 0;
49
+ }
50
+
51
+ summarize() {
52
+ const { io } = this;
53
+ io.log("");
54
+ io.log(
55
+ "==================================================================================",
56
+ );
57
+ io.log(
58
+ `🩺 doctor summary: ${this.passed} checks passed, ` +
59
+ `${this.warnings.length} warnings, ${this.problems.length} problems`,
60
+ );
61
+ if (!this.hasProblems) {
62
+ io.log("");
63
+ io.log("everything looks healthy 😻");
64
+ return;
65
+ }
66
+ io.log("");
67
+ for (const { section, message, remediation } of this.problems) {
68
+ io.log(`❌ [${section}] ${message}`);
69
+ if (remediation) io.log(` ↳ ${remediation}`);
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,77 @@
1
+ import { exec } from "child-process-promise";
2
+ import { CATLADDER_REGISTRY_NAME } from "../../../../../gcloud/artifactsRegistry";
3
+
4
+ type IamBinding = { role: string; members?: string[] };
5
+
6
+ /**
7
+ * read-only gcloud queries for the doctor, cached per project: checking
8
+ * many component/env contexts of the same gcloud project costs one
9
+ * `get-iam-policy` / `services list` call, not one per context
10
+ */
11
+ export class GcloudProjectInspector {
12
+ private readonly iamBindings = new Map<string, Promise<IamBinding[]>>();
13
+ private readonly enabledServices = new Map<string, Promise<Set<string>>>();
14
+
15
+ async isAuthenticated(): Promise<boolean> {
16
+ try {
17
+ await exec("gcloud auth print-access-token");
18
+ return true;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ private getIamBindings(projectId: string): Promise<IamBinding[]> {
25
+ if (!this.iamBindings.has(projectId)) {
26
+ this.iamBindings.set(
27
+ projectId,
28
+ exec(`gcloud projects get-iam-policy ${projectId} --format=json`).then(
29
+ (result) => JSON.parse(result.stdout).bindings ?? [],
30
+ ),
31
+ );
32
+ }
33
+ return this.iamBindings.get(projectId)!;
34
+ }
35
+
36
+ /** all roles bound to the given member (e.g. "serviceAccount:<email>") */
37
+ async getRolesOfMember(projectId: string, member: string): Promise<string[]> {
38
+ const bindings = await this.getIamBindings(projectId);
39
+ return bindings
40
+ .filter((binding) => binding.members?.includes(member))
41
+ .map((binding) => binding.role);
42
+ }
43
+
44
+ getEnabledServices(projectId: string): Promise<Set<string>> {
45
+ if (!this.enabledServices.has(projectId)) {
46
+ this.enabledServices.set(
47
+ projectId,
48
+ exec(
49
+ `gcloud services list --enabled --project=${projectId} --format="value(config.name)"`,
50
+ ).then(
51
+ (result) =>
52
+ new Set(
53
+ result.stdout
54
+ .split("\n")
55
+ .map((line) => line.trim())
56
+ .filter(Boolean),
57
+ ),
58
+ ),
59
+ );
60
+ }
61
+ return this.enabledServices.get(projectId)!;
62
+ }
63
+
64
+ async hasArtifactsRegistry(
65
+ projectId: string,
66
+ region: string,
67
+ ): Promise<boolean> {
68
+ try {
69
+ await exec(
70
+ `gcloud artifacts repositories describe ${CATLADDER_REGISTRY_NAME} --project="${projectId}" --location=${region}`,
71
+ );
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+ }
@@ -0,0 +1,148 @@
1
+ import type { ComponentContext } from "@catladder/pipeline";
2
+ import { isOfDeployType } from "@catladder/pipeline";
3
+ import { CATLADDER_REGISTRY_NAME } from "../../../../../gcloud/artifactsRegistry";
4
+ import { getGcloudServiceAccountNames } from "../../../../../gcloud/serviceAccountNames";
5
+ import { accountExists } from "../../../../../gcloud/serviceAccounts";
6
+ import { mapWithConcurrency } from "../../../../../utils/concurrency";
7
+ import {
8
+ CLOUD_RUN_DEPLOY_SA_NAME,
9
+ getCloudRunDeployRoles,
10
+ getCloudRunRequiredServices,
11
+ } from "../setup/setupCloudRun";
12
+ import type { DoctorReport } from "./DoctorReport";
13
+ import { GcloudProjectInspector } from "./GcloudProjectInspector";
14
+
15
+ const getCloudRunConfig = (context: ComponentContext) => {
16
+ const config = context.deploy?.config;
17
+ if (!isOfDeployType(config, "google-cloudrun")) {
18
+ throw new Error("not a cloud run context");
19
+ }
20
+ return config;
21
+ };
22
+
23
+ /**
24
+ * compares the gcloud state `setupCloudRun` would provision for the
25
+ * CURRENT config against what is actually there: enabled services and
26
+ * artifacts registry per project, deploy service account existence and
27
+ * IAM roles per component/env.
28
+ *
29
+ * The motivating case (#70): a component gets `cloudSql` after its
30
+ * setup ran — deploys keep working and only the review teardown hits
31
+ * the missing `roles/cloudsql.admin` as a 403 in ci.
32
+ */
33
+ export const checkCloudRun = async (
34
+ report: DoctorReport,
35
+ contexts: ComponentContext[],
36
+ ) => {
37
+ const cloudRunContexts = contexts.filter((context) =>
38
+ isOfDeployType(context.deploy?.config, "google-cloudrun"),
39
+ );
40
+ if (cloudRunContexts.length === 0) return;
41
+
42
+ report.section("google cloud run infrastructure");
43
+ const inspector = new GcloudProjectInspector();
44
+ if (!(await inspector.isAuthenticated())) {
45
+ report.fail(
46
+ "gcloud is not authenticated — all google cloud checks skipped",
47
+ "run: gcloud auth login",
48
+ );
49
+ return;
50
+ }
51
+
52
+ // project-level: enabled services + artifacts registry. The required
53
+ // services are the union over all components deploying to the project
54
+ // (cloudSql on any of them requires the sql services).
55
+ const byProject = new Map<string, ComponentContext[]>();
56
+ for (const context of cloudRunContexts) {
57
+ const { projectId } = getCloudRunConfig(context);
58
+ byProject.set(projectId, [...(byProject.get(projectId) ?? []), context]);
59
+ }
60
+
61
+ for (const [projectId, projectContexts] of byProject) {
62
+ const requiredServices = new Set(
63
+ projectContexts.flatMap((context) =>
64
+ getCloudRunRequiredServices(getCloudRunConfig(context)),
65
+ ),
66
+ );
67
+ try {
68
+ const enabled = await inspector.getEnabledServices(projectId);
69
+ const missing = [...requiredServices].filter(
70
+ (service) => !enabled.has(service),
71
+ );
72
+ if (missing.length === 0) {
73
+ report.ok(
74
+ `${projectId}: all ${requiredServices.size} required services enabled`,
75
+ );
76
+ } else {
77
+ report.fail(
78
+ `${projectId}: services not enabled: ${missing.join(", ")}`,
79
+ "run: catladder project setup",
80
+ );
81
+ }
82
+ } catch (e) {
83
+ report.warn(
84
+ `${projectId}: could not list enabled services (${e.message})`,
85
+ );
86
+ }
87
+
88
+ const regions = new Set(
89
+ projectContexts.map((context) => getCloudRunConfig(context).region),
90
+ );
91
+ for (const region of regions) {
92
+ if (await inspector.hasArtifactsRegistry(projectId, region)) {
93
+ report.ok(
94
+ `${projectId}: artifacts registry '${CATLADDER_REGISTRY_NAME}' exists in ${region}`,
95
+ );
96
+ } else {
97
+ report.fail(
98
+ `${projectId}: artifacts registry '${CATLADDER_REGISTRY_NAME}' missing in ${region}`,
99
+ "run: catladder project setup",
100
+ );
101
+ }
102
+ }
103
+ }
104
+
105
+ // per component/env: deploy service account exists and carries every
106
+ // role the current config implies
107
+ const results = await mapWithConcurrency(
108
+ cloudRunContexts,
109
+ 5,
110
+ async (context) => {
111
+ const config = getCloudRunConfig(context);
112
+ const { fullIdentifier } = getGcloudServiceAccountNames(context, {
113
+ name: CLOUD_RUN_DEPLOY_SA_NAME,
114
+ projectId: config.projectId,
115
+ });
116
+ if (!(await accountExists(fullIdentifier))) {
117
+ return { context, fullIdentifier, exists: false, missingRoles: [] };
118
+ }
119
+ const boundRoles = await inspector.getRolesOfMember(
120
+ config.projectId,
121
+ `serviceAccount:${fullIdentifier}`,
122
+ );
123
+ const missingRoles = getCloudRunDeployRoles(config).filter(
124
+ (role) => !boundRoles.includes(role),
125
+ );
126
+ return { context, fullIdentifier, exists: true, missingRoles };
127
+ },
128
+ );
129
+
130
+ for (const { context, fullIdentifier, exists, missingRoles } of results) {
131
+ const label = `${context.env}:${context.name}`;
132
+ if (!exists) {
133
+ report.fail(
134
+ `${label}: deploy service account ${fullIdentifier} does not exist`,
135
+ `run: catladder project setup ${context.name}`,
136
+ );
137
+ } else if (missingRoles.length > 0) {
138
+ report.fail(
139
+ `${label}: service account is missing roles: ${missingRoles.join(", ")}`,
140
+ `run: catladder project setup ${context.name}`,
141
+ );
142
+ } else {
143
+ report.ok(
144
+ `${label}: service account exists with all config-implied roles`,
145
+ );
146
+ }
147
+ }
148
+ };
@@ -0,0 +1,156 @@
1
+ import type { Config } from "@catladder/pipeline";
2
+ import {
3
+ getEnabledPipelineTypes,
4
+ getPipelineGitRemote,
5
+ } from "@catladder/pipeline";
6
+ import {
7
+ collectSecretTargets,
8
+ getConfiguredGithubRepo,
9
+ } from "../../../../../commands/project/commandSecretsSyncGithub";
10
+ import { mapWithConcurrency } from "../../../../../utils/concurrency";
11
+ import {
12
+ isGhAuthenticated,
13
+ listGithubEnvironments,
14
+ listGithubSecretNames,
15
+ listGithubVariableNames,
16
+ } from "../../../../../utils/github";
17
+ import type { DoctorReport } from "./DoctorReport";
18
+
19
+ /** marker for repo-level (environment-less) targets, see collectSecretTargets */
20
+ const REPO_LEVEL = "";
21
+
22
+ /**
23
+ * compares the secret/variable NAMES the generated github workflows
24
+ * reference against what is actually set — per github environment, the
25
+ * same mapping `project secrets-sync-github` writes. Values cannot be
26
+ * compared: github secrets are write-only by design (the vault stays
27
+ * the source of truth).
28
+ */
29
+ export const checkGithub = async (report: DoctorReport, config: Config) => {
30
+ if (!getEnabledPipelineTypes(config).includes("github")) return;
31
+ report.section("github environments & secrets");
32
+
33
+ if (!(await isGhAuthenticated())) {
34
+ report.warn(
35
+ "github cli (gh) not authenticated — github checks skipped",
36
+ "run: gh auth login",
37
+ );
38
+ return;
39
+ }
40
+ const repo = await getConfiguredGithubRepo();
41
+ if (!repo) {
42
+ report.fail(
43
+ `no github repository found on the '${getPipelineGitRemote(config, "github")}' remote`,
44
+ );
45
+ return;
46
+ }
47
+
48
+ let targets: Map<string, Set<string>>;
49
+ try {
50
+ targets = await collectSecretTargets(config);
51
+ } catch (e) {
52
+ report.warn(
53
+ `could not generate the github workflows to derive secret references (${e.message})`,
54
+ );
55
+ return;
56
+ }
57
+
58
+ const referencedNamesOf = (environment: string) =>
59
+ [...targets.entries()]
60
+ .filter(([, envs]) => envs.has(environment))
61
+ .map(([name]) => name);
62
+
63
+ const environmentsNeeded = [
64
+ ...new Set([...targets.values()].flatMap((envs) => [...envs])),
65
+ ]
66
+ .filter((environment) => environment !== REPO_LEVEL)
67
+ .sort();
68
+
69
+ let existingEnvironments: Set<string>;
70
+ try {
71
+ existingEnvironments = new Set(await listGithubEnvironments(repo));
72
+ } catch (e) {
73
+ report.warn(`could not list environments of ${repo} (${e.message})`);
74
+ return;
75
+ }
76
+
77
+ const perEnvironment = await mapWithConcurrency(
78
+ environmentsNeeded,
79
+ 8,
80
+ async (environment) => {
81
+ const referenced = referencedNamesOf(environment);
82
+ if (!existingEnvironments.has(environment)) {
83
+ return { environment, referenced, exists: false as const };
84
+ }
85
+ const [secrets, variables] = await Promise.all([
86
+ listGithubSecretNames(repo, environment),
87
+ listGithubVariableNames(repo, environment),
88
+ ]);
89
+ // github secret names are case-insensitive and the api returns
90
+ // them uppercased — compare accordingly
91
+ const present = new Set(
92
+ [...secrets, ...variables].map((name) => name.toUpperCase()),
93
+ );
94
+ const referencedUpper = new Set(
95
+ referenced.map((name) => name.toUpperCase()),
96
+ );
97
+ return {
98
+ environment,
99
+ referenced,
100
+ exists: true as const,
101
+ missing: referenced.filter((name) => !present.has(name.toUpperCase())),
102
+ stale: [...present].filter((name) => !referencedUpper.has(name)),
103
+ };
104
+ },
105
+ );
106
+
107
+ for (const result of perEnvironment) {
108
+ const { environment, referenced } = result;
109
+ if (!result.exists) {
110
+ report.fail(
111
+ `environment '${environment}' does not exist (${referenced.length} referenced secrets have nowhere to live)`,
112
+ "run: catladder project secrets-sync-github",
113
+ );
114
+ continue;
115
+ }
116
+ if (result.missing.length > 0) {
117
+ report.fail(
118
+ `${environment}: missing ${result.missing.length}/${referenced.length} referenced secrets: ${result.missing.join(", ")}`,
119
+ "run: catladder project secrets-sync-github",
120
+ );
121
+ } else {
122
+ report.ok(
123
+ `${environment}: all ${referenced.length} referenced secrets/variables present`,
124
+ );
125
+ }
126
+ if (result.stale.length > 0) {
127
+ report.warn(
128
+ `${environment}: ${result.stale.length} set but not referenced by any workflow (stale?): ${result.stale.join(", ")}`,
129
+ );
130
+ }
131
+ }
132
+
133
+ const repoLevelReferenced = referencedNamesOf(REPO_LEVEL);
134
+ if (repoLevelReferenced.length > 0) {
135
+ const [secrets, variables] = await Promise.all([
136
+ listGithubSecretNames(repo),
137
+ listGithubVariableNames(repo),
138
+ ]);
139
+ const present = new Set(
140
+ [...secrets, ...variables].map((name) => name.toUpperCase()),
141
+ );
142
+ const missing = repoLevelReferenced.filter(
143
+ (name) => !present.has(name.toUpperCase()),
144
+ );
145
+ if (missing.length > 0) {
146
+ report.fail(
147
+ `repo level: missing ${missing.length}/${repoLevelReferenced.length} referenced secrets: ${missing.join(", ")}`,
148
+ "run: catladder project secrets-sync-github",
149
+ );
150
+ } else {
151
+ report.ok(
152
+ `repo level: all ${repoLevelReferenced.length} referenced secrets/variables present`,
153
+ );
154
+ }
155
+ }
156
+ };
@@ -0,0 +1,133 @@
1
+ import type { ComponentContext } from "@catladder/pipeline";
2
+ import { isOfDeployType } from "@catladder/pipeline";
3
+ import { differenceInCalendarDays } from "date-fns";
4
+ import type { IO } from "../../../../../core/types";
5
+ import {
6
+ doGitlabRequest,
7
+ doGitlabRequestAllPages,
8
+ getProjectInfo,
9
+ } from "../../../../../utils/gitlab";
10
+ import type { DoctorReport } from "./DoctorReport";
11
+
12
+ const EXPIRY_WARNING_DAYS = 30;
13
+
14
+ /**
15
+ * the gitlab-side outputs of `project setup`: the semantic-release
16
+ * access token (+ its GL_TOKEN ci variable), the catladder topic and —
17
+ * for kubernetes deploys — the registry deploy token.
18
+ *
19
+ * Kubernetes namespace/rbac checks are deliberately skipped: they would
20
+ * require connecting kubectl to every cluster, which switches the local
21
+ * kubeconfig context — doctor must not change anything.
22
+ */
23
+ export const checkGitlab = async (
24
+ instance: IO,
25
+ report: DoctorReport,
26
+ contexts: ComponentContext[],
27
+ ) => {
28
+ report.section("gitlab project setup");
29
+
30
+ let projectId: string;
31
+ try {
32
+ projectId = (await getProjectInfo(instance)).id;
33
+ } catch (e) {
34
+ report.warn(
35
+ `could not reach the gitlab api — gitlab checks skipped (${e.message})`,
36
+ );
37
+ return;
38
+ }
39
+
40
+ try {
41
+ const tokens = await doGitlabRequestAllPages(
42
+ instance,
43
+ `projects/${projectId}/access_tokens`,
44
+ );
45
+ const activeTokens = tokens.filter(
46
+ (t) => t.name === "semantic-release" && t.active === true,
47
+ );
48
+ // the GL_TOKEN ci variable holds exactly one of them — judge by the
49
+ // one that lives longest
50
+ const token = activeTokens.sort((a, b) =>
51
+ String(b.expires_at).localeCompare(String(a.expires_at)),
52
+ )[0];
53
+ if (activeTokens.length > 1) {
54
+ report.warn(
55
+ `${activeTokens.length} active 'semantic-release' tokens exist — duplicates from earlier setups, consider revoking the old ones`,
56
+ );
57
+ }
58
+ if (!token) {
59
+ report.fail(
60
+ "no active 'semantic-release' project access token",
61
+ "run: catladder project setup",
62
+ );
63
+ } else {
64
+ const daysLeft = differenceInCalendarDays(
65
+ new Date(token.expires_at),
66
+ new Date(),
67
+ );
68
+ if (daysLeft < 0) {
69
+ report.fail(
70
+ `'semantic-release' token expired on ${token.expires_at}`,
71
+ "run: catladder project setup (rotates the token)",
72
+ );
73
+ } else if (daysLeft <= EXPIRY_WARNING_DAYS) {
74
+ report.warn(
75
+ `'semantic-release' token expires in ${daysLeft} days (${token.expires_at})`,
76
+ "run: catladder project setup (rotates the token)",
77
+ );
78
+ } else {
79
+ report.ok(
80
+ `'semantic-release' token active (expires ${token.expires_at})`,
81
+ );
82
+ }
83
+ }
84
+
85
+ try {
86
+ await doGitlabRequest(
87
+ instance,
88
+ `projects/${projectId}/variables/GL_TOKEN`,
89
+ );
90
+ report.ok("GL_TOKEN ci variable present");
91
+ } catch {
92
+ report.fail(
93
+ "GL_TOKEN ci variable missing",
94
+ "run: catladder project setup",
95
+ );
96
+ }
97
+
98
+ const { topics } = await doGitlabRequest(instance, `projects/${projectId}`);
99
+ if (topics?.includes("catladder")) {
100
+ report.ok("'catladder' project topic set");
101
+ } else {
102
+ report.fail(
103
+ "'catladder' project topic missing",
104
+ "run: catladder project setup",
105
+ );
106
+ }
107
+
108
+ if (
109
+ contexts.some((context) =>
110
+ isOfDeployType(context.deploy?.config, "kubernetes"),
111
+ )
112
+ ) {
113
+ const deployTokens = await doGitlabRequestAllPages(
114
+ instance,
115
+ `projects/${projectId}/deploy_tokens`,
116
+ );
117
+ if (
118
+ deployTokens.find(
119
+ (t: { name: string }) => t.name === "gitlab-deploy-token",
120
+ )
121
+ ) {
122
+ report.ok("'gitlab-deploy-token' registry deploy token present");
123
+ } else {
124
+ report.fail(
125
+ "'gitlab-deploy-token' registry deploy token missing (kubernetes cannot pull images)",
126
+ "run: catladder project setup",
127
+ );
128
+ }
129
+ }
130
+ } catch (e) {
131
+ report.warn(`gitlab checks aborted (${e.message})`);
132
+ }
133
+ };
@@ -0,0 +1,43 @@
1
+ import { getEnvConfig, readCatladderStore } from "@catladder/pipeline";
2
+ import {
3
+ getAllComponentsWithAllEnvsFlat,
4
+ getProjectConfig,
5
+ } from "../../../../../config/getProjectConfig";
6
+ import { getGitRoot } from "../../../../../utils/projects";
7
+ import type { DoctorReport } from "./DoctorReport";
8
+
9
+ /**
10
+ * every google-cloudrun project in the config needs its project number
11
+ * in the committed store — the deterministic cloud run urls
12
+ * (<service>-<projectNumber>.<region>.run.app) are computed from it at
13
+ * generation time. This check is fully offline.
14
+ */
15
+ export const checkStore = async (report: DoctorReport) => {
16
+ const config = await getProjectConfig();
17
+ const projectIds = new Set<string>();
18
+ for (const {
19
+ componentName,
20
+ env,
21
+ } of await getAllComponentsWithAllEnvsFlat()) {
22
+ const { deploy } = getEnvConfig(config, componentName, env);
23
+ if (deploy && deploy.type === "google-cloudrun") {
24
+ projectIds.add(deploy.projectId);
25
+ }
26
+ }
27
+ if (projectIds.size === 0) return;
28
+
29
+ report.section("catladder store (.catladder-store/store.yml)");
30
+ const root = (await getGitRoot()) ?? process.cwd();
31
+ const store = readCatladderStore(root);
32
+ for (const projectId of projectIds) {
33
+ const projectNumber = store.gcloudProjects?.[projectId]?.projectNumber;
34
+ if (projectNumber) {
35
+ report.ok(`project number for ${projectId}: ${projectNumber}`);
36
+ } else {
37
+ report.fail(
38
+ `missing project number for ${projectId}`,
39
+ "run: catladder project setup",
40
+ );
41
+ }
42
+ }
43
+ };
@@ -0,0 +1,59 @@
1
+ import type { ComponentContext } from "@catladder/pipeline";
2
+ import { getEnabledPipelineTypes } from "@catladder/pipeline";
3
+ import {
4
+ getAllPipelineContexts,
5
+ getProjectConfig,
6
+ } from "../../../../../config/getProjectConfig";
7
+ import type { IO } from "../../../../../core/types";
8
+ import { checkCloudRun } from "./checkCloudRun";
9
+ import { checkGithub } from "./checkGithub";
10
+ import { checkGitlab } from "./checkGitlab";
11
+ import { checkStore } from "./checkStore";
12
+ import { DoctorReport } from "./DoctorReport";
13
+
14
+ /**
15
+ * read-only health check: compares the state `project setup` (and
16
+ * `project secrets-sync-github`) would provision for the CURRENT config
17
+ * against what is actually there, and reports drift together with the
18
+ * command that heals it. `project setup` is a point-in-time
19
+ * provisioner, so config edits that change required infrastructure
20
+ * (IAM roles, services, secrets) drift silently until something breaks
21
+ * in ci — the doctor surfaces that before it does.
22
+ *
23
+ * The component filter only narrows the per-component checks; the
24
+ * cheap project-global checks (store, gitlab, github) always run.
25
+ */
26
+ export const doctorProject = async (
27
+ instance: IO,
28
+ onlyComponents?: string | string[],
29
+ ) => {
30
+ const config = await getProjectConfig();
31
+ if (!config) {
32
+ throw new Error("no catladder config found");
33
+ }
34
+ instance.log(
35
+ "🐱 🩺 checking project health — read-only, nothing will be changed",
36
+ );
37
+
38
+ const report = new DoctorReport(instance);
39
+ await checkStore(report);
40
+
41
+ let contexts: ComponentContext[] = [];
42
+ try {
43
+ contexts = await getAllPipelineContexts(onlyComponents);
44
+ } catch (e) {
45
+ report.section("pipeline contexts");
46
+ report.fail(
47
+ `could not create pipeline contexts: ${e.message}`,
48
+ "run: catladder project setup",
49
+ );
50
+ }
51
+
52
+ if (getEnabledPipelineTypes(config).includes("gitlab")) {
53
+ await checkGitlab(instance, report, contexts);
54
+ }
55
+ await checkCloudRun(report, contexts);
56
+ await checkGithub(report, config);
57
+
58
+ report.summarize();
59
+ };