@intentius/chant-lexicon-k8s 0.0.15 → 0.0.16

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.
@@ -0,0 +1,219 @@
1
+ /**
2
+ * GkeFluentBitAgent composite — DaemonSet + RBAC + ConfigMap for Fluent Bit on GKE.
3
+ *
4
+ * @gke Like FluentBitAgent but targets Cloud Logging via the stackdriver
5
+ * output plugin and uses GKE Workload Identity instead of IRSA.
6
+ */
7
+
8
+ export interface GkeFluentBitAgentProps {
9
+ /** GKE cluster name — used as log stream prefix. */
10
+ clusterName: string;
11
+ /** GCP project ID. */
12
+ projectId: string;
13
+ /** GCP service account email for Workload Identity. */
14
+ gcpServiceAccountEmail?: string;
15
+ /** Agent name (default: "fluent-bit"). */
16
+ name?: string;
17
+ /** Fluent Bit image (default: "fluent/fluent-bit:latest"). */
18
+ image?: string;
19
+ /** Namespace (default: "gke-logging"). */
20
+ namespace?: string;
21
+ /** Additional labels. */
22
+ labels?: Record<string, string>;
23
+ /** CPU request (default: "50m"). */
24
+ cpuRequest?: string;
25
+ /** Memory request (default: "64Mi"). */
26
+ memoryRequest?: string;
27
+ /** CPU limit (default: "200m"). */
28
+ cpuLimit?: string;
29
+ /** Memory limit (default: "128Mi"). */
30
+ memoryLimit?: string;
31
+ }
32
+
33
+ export interface GkeFluentBitAgentResult {
34
+ daemonSet: Record<string, unknown>;
35
+ serviceAccount: Record<string, unknown>;
36
+ clusterRole: Record<string, unknown>;
37
+ clusterRoleBinding: Record<string, unknown>;
38
+ configMap: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Create a GkeFluentBitAgent composite — returns prop objects for
43
+ * a DaemonSet, ServiceAccount, ClusterRole, ClusterRoleBinding, and ConfigMap.
44
+ *
45
+ * @gke
46
+ * @example
47
+ * ```ts
48
+ * import { GkeFluentBitAgent } from "@intentius/chant-lexicon-k8s";
49
+ *
50
+ * const { daemonSet, serviceAccount, clusterRole, clusterRoleBinding, configMap } = GkeFluentBitAgent({
51
+ * clusterName: "my-cluster",
52
+ * projectId: "my-project",
53
+ * gcpServiceAccountEmail: "fluent-bit@my-project.iam.gserviceaccount.com",
54
+ * });
55
+ * ```
56
+ */
57
+ export function GkeFluentBitAgent(props: GkeFluentBitAgentProps): GkeFluentBitAgentResult {
58
+ const {
59
+ clusterName,
60
+ projectId,
61
+ gcpServiceAccountEmail,
62
+ name = "fluent-bit",
63
+ image = "fluent/fluent-bit:latest",
64
+ namespace = "gke-logging",
65
+ labels: extraLabels = {},
66
+ cpuRequest = "50m",
67
+ memoryRequest = "64Mi",
68
+ cpuLimit = "200m",
69
+ memoryLimit = "128Mi",
70
+ } = props;
71
+
72
+ const saName = `${name}-sa`;
73
+ const clusterRoleName = `${name}-role`;
74
+ const bindingName = `${name}-binding`;
75
+ const configMapName = `${name}-config`;
76
+
77
+ const commonLabels: Record<string, string> = {
78
+ "app.kubernetes.io/name": name,
79
+ "app.kubernetes.io/managed-by": "chant",
80
+ ...extraLabels,
81
+ };
82
+
83
+ const fluentBitConfig = `[SERVICE]
84
+ Flush 5
85
+ Log_Level info
86
+ Daemon off
87
+ Parsers_File parsers.conf
88
+
89
+ [INPUT]
90
+ Name tail
91
+ Tag kube.*
92
+ Path /var/log/containers/*.log
93
+ Parser docker
94
+ DB /var/fluent-bit/state/flb_container.db
95
+ Mem_Buf_Limit 5MB
96
+ Skip_Long_Lines On
97
+ Refresh_Interval 10
98
+
99
+ [FILTER]
100
+ Name kubernetes
101
+ Match kube.*
102
+ Kube_URL https://kubernetes.default.svc:443
103
+ Kube_CA_File /var/run/secrets/kubernetes.io/serviceaccount/ca.crt
104
+ Kube_Token_File /var/run/secrets/kubernetes.io/serviceaccount/token
105
+ Merge_Log On
106
+ K8S-Logging.Parser On
107
+ K8S-Logging.Exclude Off
108
+
109
+ [OUTPUT]
110
+ Name stackdriver
111
+ Match *
112
+ google_service_credentials /var/run/secrets/kubernetes.io/serviceaccount/token
113
+ resource k8s_container
114
+ k8s_cluster_name ${clusterName}
115
+ k8s_cluster_location ${projectId}
116
+ `;
117
+
118
+ const container: Record<string, unknown> = {
119
+ name,
120
+ image,
121
+ resources: {
122
+ requests: { cpu: cpuRequest, memory: memoryRequest },
123
+ limits: { cpu: cpuLimit, memory: memoryLimit },
124
+ },
125
+ volumeMounts: [
126
+ { name: "varlog", mountPath: "/var/log", readOnly: true },
127
+ { name: "config", mountPath: `/etc/${name}`, readOnly: true },
128
+ { name: "state", mountPath: "/var/fluent-bit/state" },
129
+ ],
130
+ securityContext: {
131
+ runAsUser: 0,
132
+ readOnlyRootFilesystem: true,
133
+ allowPrivilegeEscalation: false,
134
+ },
135
+ };
136
+
137
+ const daemonSetProps: Record<string, unknown> = {
138
+ metadata: {
139
+ name,
140
+ namespace,
141
+ labels: { ...commonLabels, "app.kubernetes.io/component": "agent" },
142
+ },
143
+ spec: {
144
+ selector: { matchLabels: { "app.kubernetes.io/name": name } },
145
+ template: {
146
+ metadata: { labels: { "app.kubernetes.io/name": name, ...extraLabels } },
147
+ spec: {
148
+ serviceAccountName: saName,
149
+ containers: [container],
150
+ volumes: [
151
+ { name: "varlog", hostPath: { path: "/var/log" } },
152
+ { name: "config", configMap: { name: configMapName } },
153
+ { name: "state", hostPath: { path: `/var/fluent-bit/state/${name}` } },
154
+ ],
155
+ tolerations: [{ operator: "Exists" }],
156
+ },
157
+ },
158
+ },
159
+ };
160
+
161
+ const serviceAccountProps: Record<string, unknown> = {
162
+ metadata: {
163
+ name: saName,
164
+ namespace,
165
+ labels: { ...commonLabels, "app.kubernetes.io/component": "agent" },
166
+ ...(gcpServiceAccountEmail
167
+ ? { annotations: { "iam.gke.io/gcp-service-account": gcpServiceAccountEmail } }
168
+ : {}),
169
+ },
170
+ };
171
+
172
+ const clusterRoleProps: Record<string, unknown> = {
173
+ metadata: {
174
+ name: clusterRoleName,
175
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
176
+ },
177
+ rules: [
178
+ { apiGroups: [""], resources: ["namespaces", "pods"], verbs: ["get", "list", "watch"] },
179
+ ],
180
+ };
181
+
182
+ const clusterRoleBindingProps: Record<string, unknown> = {
183
+ metadata: {
184
+ name: bindingName,
185
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
186
+ },
187
+ roleRef: {
188
+ apiGroup: "rbac.authorization.k8s.io",
189
+ kind: "ClusterRole",
190
+ name: clusterRoleName,
191
+ },
192
+ subjects: [
193
+ {
194
+ kind: "ServiceAccount",
195
+ name: saName,
196
+ namespace,
197
+ },
198
+ ],
199
+ };
200
+
201
+ const configMapProps: Record<string, unknown> = {
202
+ metadata: {
203
+ name: configMapName,
204
+ namespace,
205
+ labels: { ...commonLabels, "app.kubernetes.io/component": "config" },
206
+ },
207
+ data: {
208
+ "fluent-bit.conf": fluentBitConfig,
209
+ },
210
+ };
211
+
212
+ return {
213
+ daemonSet: daemonSetProps,
214
+ serviceAccount: serviceAccountProps,
215
+ clusterRole: clusterRoleProps,
216
+ clusterRoleBinding: clusterRoleBindingProps,
217
+ configMap: configMapProps,
218
+ };
219
+ }
@@ -0,0 +1,229 @@
1
+ /**
2
+ * GkeOtelCollector composite — DaemonSet + RBAC + ConfigMap for OpenTelemetry on GKE.
3
+ *
4
+ * @gke Like AdotCollector but targets Cloud Trace + Cloud Monitoring via the
5
+ * googlecloud exporter and uses GKE Workload Identity instead of IRSA.
6
+ */
7
+
8
+ export interface GkeOtelCollectorProps {
9
+ /** GKE cluster name. */
10
+ clusterName: string;
11
+ /** GCP project ID. */
12
+ projectId: string;
13
+ /** GCP service account email for Workload Identity. */
14
+ gcpServiceAccountEmail?: string;
15
+ /** Agent name (default: "gke-otel-collector"). */
16
+ name?: string;
17
+ /** OTel Collector image (default: "otel/opentelemetry-collector-contrib:latest"). */
18
+ image?: string;
19
+ /** Namespace (default: "gke-monitoring"). */
20
+ namespace?: string;
21
+ /** Additional labels. */
22
+ labels?: Record<string, string>;
23
+ /** CPU request (default: "100m"). */
24
+ cpuRequest?: string;
25
+ /** Memory request (default: "256Mi"). */
26
+ memoryRequest?: string;
27
+ /** CPU limit (default: "500m"). */
28
+ cpuLimit?: string;
29
+ /** Memory limit (default: "512Mi"). */
30
+ memoryLimit?: string;
31
+ }
32
+
33
+ export interface GkeOtelCollectorResult {
34
+ daemonSet: Record<string, unknown>;
35
+ serviceAccount: Record<string, unknown>;
36
+ clusterRole: Record<string, unknown>;
37
+ clusterRoleBinding: Record<string, unknown>;
38
+ configMap: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Create a GkeOtelCollector composite — returns prop objects for
43
+ * a DaemonSet, ServiceAccount, ClusterRole, ClusterRoleBinding, and ConfigMap.
44
+ *
45
+ * @gke
46
+ * @example
47
+ * ```ts
48
+ * import { GkeOtelCollector } from "@intentius/chant-lexicon-k8s";
49
+ *
50
+ * const { daemonSet, serviceAccount, clusterRole, clusterRoleBinding, configMap } = GkeOtelCollector({
51
+ * clusterName: "my-cluster",
52
+ * projectId: "my-project",
53
+ * gcpServiceAccountEmail: "otel@my-project.iam.gserviceaccount.com",
54
+ * });
55
+ * ```
56
+ */
57
+ export function GkeOtelCollector(props: GkeOtelCollectorProps): GkeOtelCollectorResult {
58
+ const {
59
+ clusterName,
60
+ projectId,
61
+ gcpServiceAccountEmail,
62
+ name = "gke-otel-collector",
63
+ image = "otel/opentelemetry-collector-contrib:latest",
64
+ namespace = "gke-monitoring",
65
+ labels: extraLabels = {},
66
+ cpuRequest = "100m",
67
+ memoryRequest = "256Mi",
68
+ cpuLimit = "500m",
69
+ memoryLimit = "512Mi",
70
+ } = props;
71
+
72
+ const saName = `${name}-sa`;
73
+ const clusterRoleName = `${name}-role`;
74
+ const bindingName = `${name}-binding`;
75
+ const configMapName = `${name}-config`;
76
+
77
+ const commonLabels: Record<string, string> = {
78
+ "app.kubernetes.io/name": name,
79
+ "app.kubernetes.io/managed-by": "chant",
80
+ ...extraLabels,
81
+ };
82
+
83
+ const otelConfig = `receivers:
84
+ otlp:
85
+ protocols:
86
+ grpc:
87
+ endpoint: 0.0.0.0:4317
88
+ http:
89
+ endpoint: 0.0.0.0:4318
90
+
91
+ processors:
92
+ batch:
93
+ timeout: 30s
94
+ send_batch_size: 8192
95
+ resourcedetection:
96
+ detectors: [gcp]
97
+ timeout: 10s
98
+
99
+ exporters:
100
+ googlecloud:
101
+ project: ${projectId}
102
+ metric:
103
+ prefix: custom.googleapis.com/${clusterName}
104
+ trace:
105
+ attribute_mappings:
106
+ - key: service.name
107
+ replacement: g.co/r/service/name
108
+
109
+ service:
110
+ pipelines:
111
+ metrics:
112
+ receivers: [otlp]
113
+ processors: [batch, resourcedetection]
114
+ exporters: [googlecloud]
115
+ traces:
116
+ receivers: [otlp]
117
+ processors: [batch, resourcedetection]
118
+ exporters: [googlecloud]
119
+ `;
120
+
121
+ const container: Record<string, unknown> = {
122
+ name,
123
+ image,
124
+ args: ["--config=/etc/otel/config.yaml"],
125
+ ports: [
126
+ { containerPort: 4317, name: "otlp-grpc" },
127
+ { containerPort: 4318, name: "otlp-http" },
128
+ ],
129
+ resources: {
130
+ requests: { cpu: cpuRequest, memory: memoryRequest },
131
+ limits: { cpu: cpuLimit, memory: memoryLimit },
132
+ },
133
+ volumeMounts: [
134
+ { name: "config", mountPath: "/etc/otel", readOnly: true },
135
+ ],
136
+ securityContext: {
137
+ runAsNonRoot: true,
138
+ runAsUser: 10001,
139
+ readOnlyRootFilesystem: true,
140
+ allowPrivilegeEscalation: false,
141
+ },
142
+ };
143
+
144
+ const daemonSetProps: Record<string, unknown> = {
145
+ metadata: {
146
+ name,
147
+ namespace,
148
+ labels: { ...commonLabels, "app.kubernetes.io/component": "agent" },
149
+ },
150
+ spec: {
151
+ selector: { matchLabels: { "app.kubernetes.io/name": name } },
152
+ template: {
153
+ metadata: { labels: { "app.kubernetes.io/name": name, ...extraLabels } },
154
+ spec: {
155
+ serviceAccountName: saName,
156
+ containers: [container],
157
+ volumes: [
158
+ { name: "config", configMap: { name: configMapName } },
159
+ ],
160
+ tolerations: [{ operator: "Exists" }],
161
+ },
162
+ },
163
+ },
164
+ };
165
+
166
+ const serviceAccountProps: Record<string, unknown> = {
167
+ metadata: {
168
+ name: saName,
169
+ namespace,
170
+ labels: { ...commonLabels, "app.kubernetes.io/component": "agent" },
171
+ ...(gcpServiceAccountEmail
172
+ ? { annotations: { "iam.gke.io/gcp-service-account": gcpServiceAccountEmail } }
173
+ : {}),
174
+ },
175
+ };
176
+
177
+ const clusterRoleProps: Record<string, unknown> = {
178
+ metadata: {
179
+ name: clusterRoleName,
180
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
181
+ },
182
+ rules: [
183
+ { apiGroups: [""], resources: ["pods", "nodes", "endpoints"], verbs: ["get", "list", "watch"] },
184
+ { apiGroups: ["apps"], resources: ["replicasets"], verbs: ["get", "list", "watch"] },
185
+ { apiGroups: ["batch"], resources: ["jobs"], verbs: ["get", "list", "watch"] },
186
+ { apiGroups: [""], resources: ["nodes/proxy"], verbs: ["get"] },
187
+ { apiGroups: [""], resources: ["nodes/stats", "configmaps", "events"], verbs: ["create", "get"] },
188
+ { apiGroups: [""], resources: ["configmaps"], verbs: ["get", "update", "create"], resourceNames: ["otel-container-insight-clusterleader"] },
189
+ ],
190
+ };
191
+
192
+ const clusterRoleBindingProps: Record<string, unknown> = {
193
+ metadata: {
194
+ name: bindingName,
195
+ labels: { ...commonLabels, "app.kubernetes.io/component": "rbac" },
196
+ },
197
+ roleRef: {
198
+ apiGroup: "rbac.authorization.k8s.io",
199
+ kind: "ClusterRole",
200
+ name: clusterRoleName,
201
+ },
202
+ subjects: [
203
+ {
204
+ kind: "ServiceAccount",
205
+ name: saName,
206
+ namespace,
207
+ },
208
+ ],
209
+ };
210
+
211
+ const configMapProps: Record<string, unknown> = {
212
+ metadata: {
213
+ name: configMapName,
214
+ namespace,
215
+ labels: { ...commonLabels, "app.kubernetes.io/component": "config" },
216
+ },
217
+ data: {
218
+ "config.yaml": otelConfig,
219
+ },
220
+ };
221
+
222
+ return {
223
+ daemonSet: daemonSetProps,
224
+ serviceAccount: serviceAccountProps,
225
+ clusterRole: clusterRoleProps,
226
+ clusterRoleBinding: clusterRoleBindingProps,
227
+ configMap: configMapProps,
228
+ };
229
+ }
@@ -51,6 +51,8 @@ export { GkeGateway } from "./gke-gateway";
51
51
  export type { GkeGatewayProps, GkeGatewayResult } from "./gke-gateway";
52
52
  export { ConfigConnectorContext } from "./config-connector-context";
53
53
  export type { ConfigConnectorContextProps, ConfigConnectorContextResult } from "./config-connector-context";
54
+ export { GceIngress } from "./gce-ingress";
55
+ export type { GceIngressProps, GceIngressResult } from "./gce-ingress";
54
56
  export { AgicIngress } from "./agic-ingress";
55
57
  export type { AgicIngressProps, AgicIngressResult } from "./agic-ingress";
56
58
  export { AzureDiskStorageClass } from "./azure-disk-storage-class";
@@ -59,3 +61,13 @@ export { AzureFileStorageClass } from "./azure-file-storage-class";
59
61
  export type { AzureFileStorageClassProps, AzureFileStorageClassResult } from "./azure-file-storage-class";
60
62
  export { AzureMonitorCollector } from "./azure-monitor-collector";
61
63
  export type { AzureMonitorCollectorProps, AzureMonitorCollectorResult } from "./azure-monitor-collector";
64
+ export { WorkloadIdentityServiceAccount as AksWorkloadIdentityServiceAccount } from "./workload-identity-sa";
65
+ export type { WorkloadIdentityServiceAccountProps as AksWorkloadIdentityServiceAccountProps, WorkloadIdentityServiceAccountResult as AksWorkloadIdentityServiceAccountResult } from "./workload-identity-sa";
66
+ export { GkeFluentBitAgent } from "./gke-fluent-bit-agent";
67
+ export type { GkeFluentBitAgentProps, GkeFluentBitAgentResult } from "./gke-fluent-bit-agent";
68
+ export { GkeOtelCollector } from "./gke-otel-collector";
69
+ export type { GkeOtelCollectorProps, GkeOtelCollectorResult } from "./gke-otel-collector";
70
+ export { GkeExternalDnsAgent } from "./gke-external-dns-agent";
71
+ export type { GkeExternalDnsAgentProps, GkeExternalDnsAgentResult } from "./gke-external-dns-agent";
72
+ export { AksExternalDnsAgent } from "./aks-external-dns-agent";
73
+ export type { AksExternalDnsAgentProps, AksExternalDnsAgentResult } from "./aks-external-dns-agent";
package/src/index.ts CHANGED
@@ -21,6 +21,10 @@ export {
21
21
  BatchJob, SecureIngress, ConfiguredApp, SidecarApp, MonitoredService, NetworkIsolatedApp,
22
22
  IrsaServiceAccount, AlbIngress, EbsStorageClass, EfsStorageClass, FluentBitAgent, ExternalDnsAgent, AdotCollector,
23
23
  MetricsServer, WorkloadIdentityServiceAccount, GcePdStorageClass, FilestoreStorageClass, GkeGateway, ConfigConnectorContext,
24
+ GceIngress,
25
+ AgicIngress, AzureDiskStorageClass, AzureFileStorageClass, AzureMonitorCollector,
26
+ AksWorkloadIdentityServiceAccount,
27
+ GkeFluentBitAgent, GkeOtelCollector, GkeExternalDnsAgent, AksExternalDnsAgent,
24
28
  } from "./composites/index";
25
29
  export type {
26
30
  WebAppProps, WebAppResult, StatefulAppProps, StatefulAppResult, CronWorkloadProps, CronWorkloadResult,
@@ -39,6 +43,16 @@ export type {
39
43
  FilestoreStorageClassProps, FilestoreStorageClassResult,
40
44
  GkeGatewayProps, GkeGatewayResult,
41
45
  ConfigConnectorContextProps, ConfigConnectorContextResult,
46
+ GceIngressProps, GceIngressResult,
47
+ AgicIngressProps, AgicIngressResult,
48
+ AzureDiskStorageClassProps, AzureDiskStorageClassResult,
49
+ AzureFileStorageClassProps, AzureFileStorageClassResult,
50
+ AzureMonitorCollectorProps, AzureMonitorCollectorResult,
51
+ AksWorkloadIdentityServiceAccountProps, AksWorkloadIdentityServiceAccountResult,
52
+ GkeFluentBitAgentProps, GkeFluentBitAgentResult,
53
+ GkeOtelCollectorProps, GkeOtelCollectorResult,
54
+ GkeExternalDnsAgentProps, GkeExternalDnsAgentResult,
55
+ AksExternalDnsAgentProps, AksExternalDnsAgentResult,
42
56
  } from "./composites/index";
43
57
 
44
58
  // RBAC verb constants
@@ -0,0 +1,121 @@
1
+ import type { LintRule, LintDiagnostic, LintContext } from "@intentius/chant/lint/rule";
2
+ import * as ts from "typescript";
3
+
4
+ /**
5
+ * WK8002: Latest Image Tag
6
+ *
7
+ * Detects when a K8s workload resource uses the `:latest` image tag or no tag
8
+ * at all in a container image string literal. Untagged or `:latest` images are
9
+ * non-deterministic and can cause unexpected rollouts.
10
+ *
11
+ * Bad: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx:latest" }] } } } })
12
+ * Bad: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx" }] } } } })
13
+ * Good: new Deployment({ spec: { template: { spec: { containers: [{ image: "nginx:1.25" }] } } } })
14
+ */
15
+
16
+ const WORKLOAD_KINDS = new Set([
17
+ "Deployment",
18
+ "StatefulSet",
19
+ "DaemonSet",
20
+ "CronJob",
21
+ "Job",
22
+ "ReplicaSet",
23
+ "Pod",
24
+ ]);
25
+
26
+ /**
27
+ * Returns true if the string looks like a container image reference that is
28
+ * either untagged or using `:latest`.
29
+ *
30
+ * A string is considered a container image if it:
31
+ * - Contains at least one alphabetic character
32
+ * - Does not contain spaces
33
+ * - Is not a simple keyword like "true", "false", etc.
34
+ */
35
+ function isProblematicImage(value: string): boolean {
36
+ if (!value || value.includes(" ") || value.length === 0) return false;
37
+
38
+ // Skip values that are clearly not images
39
+ const nonImagePatterns = [
40
+ /^(true|false|null|undefined|yes|no)$/i,
41
+ /^\d+$/, // pure numbers
42
+ /^[.\/]/, // relative/absolute paths without image-like structure
43
+ ];
44
+ for (const pat of nonImagePatterns) {
45
+ if (pat.test(value)) return false;
46
+ }
47
+
48
+ // Check for :latest explicitly
49
+ if (value.endsWith(":latest")) return true;
50
+
51
+ // Check for untagged image: no colon at all, but looks like an image name
52
+ // Images contain alphanumeric chars and may have / for registry prefix
53
+ // Must have at least one alpha char and match image naming conventions
54
+ if (!value.includes(":") && !value.includes("@") && /^[a-zA-Z0-9._\-\/]+$/.test(value) && /[a-zA-Z]/.test(value)) {
55
+ return true;
56
+ }
57
+
58
+ return false;
59
+ }
60
+
61
+ export const latestImageTagRule: LintRule = {
62
+ id: "WK8002",
63
+ severity: "warning",
64
+ category: "security",
65
+ description:
66
+ "Detects :latest or untagged container images — use explicit version tags for reproducibility",
67
+
68
+ check(context: LintContext): LintDiagnostic[] {
69
+ const { sourceFile } = context;
70
+ const diagnostics: LintDiagnostic[] = [];
71
+
72
+ function isInsideWorkloadConstructor(node: ts.Node): boolean {
73
+ let current: ts.Node | undefined = node.parent;
74
+ while (current) {
75
+ if (
76
+ ts.isNewExpression(current) &&
77
+ ts.isIdentifier(current.expression) &&
78
+ WORKLOAD_KINDS.has(current.expression.text)
79
+ ) {
80
+ return true;
81
+ }
82
+ current = current.parent;
83
+ }
84
+ return false;
85
+ }
86
+
87
+ function visit(node: ts.Node): void {
88
+ // Look for property assignments like `image: "nginx:latest"` or `image: "nginx"`
89
+ if (
90
+ ts.isPropertyAssignment(node) &&
91
+ ts.isIdentifier(node.name) &&
92
+ node.name.text === "image" &&
93
+ ts.isStringLiteral(node.initializer) &&
94
+ isInsideWorkloadConstructor(node)
95
+ ) {
96
+ const value = node.initializer.text;
97
+ if (isProblematicImage(value)) {
98
+ const { line, character } =
99
+ sourceFile.getLineAndCharacterOfPosition(
100
+ node.initializer.getStart(),
101
+ );
102
+ const isLatest = value.endsWith(":latest");
103
+ diagnostics.push({
104
+ file: sourceFile.fileName,
105
+ line: line + 1,
106
+ column: character + 1,
107
+ ruleId: "WK8002",
108
+ severity: "warning",
109
+ message: isLatest
110
+ ? `Container image "${value}" uses the :latest tag. Pin to a specific version for reproducibility.`
111
+ : `Container image "${value}" has no tag. Pin to a specific version for reproducibility.`,
112
+ });
113
+ }
114
+ }
115
+ ts.forEachChild(node, visit);
116
+ }
117
+
118
+ visit(sourceFile);
119
+ return diagnostics;
120
+ },
121
+ };