@blogic-cz/agent-tools 0.15.5 → 0.15.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/package.json
CHANGED
package/src/k8s-tool/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { K8sService, K8sServiceLayer } from "./service";
|
|
|
17
17
|
import { ConfigService, ConfigServiceLayer, getDefaultEnvironment, getToolConfig } from "#config";
|
|
18
18
|
import type { K8sConfig } from "#config";
|
|
19
19
|
import { K8sContextError } from "./errors";
|
|
20
|
+
import { hasKubernetesConfig, selectK8sProfile } from "./profile";
|
|
20
21
|
import {
|
|
21
22
|
transformDescribe,
|
|
22
23
|
transformGenericKubectl,
|
|
@@ -60,6 +61,21 @@ const resolveEnv = (
|
|
|
60
61
|
});
|
|
61
62
|
});
|
|
62
63
|
|
|
64
|
+
const resolveEnvAndProfile = (
|
|
65
|
+
envOption: Option.Option<string>,
|
|
66
|
+
profileOption: Option.Option<string>,
|
|
67
|
+
config: Parameters<typeof getDefaultEnvironment>[0],
|
|
68
|
+
) =>
|
|
69
|
+
Effect.gen(function* () {
|
|
70
|
+
const resolvedEnv = yield* resolveEnv(envOption, config);
|
|
71
|
+
const profileName = selectK8sProfile(
|
|
72
|
+
config?.kubernetes,
|
|
73
|
+
Option.getOrUndefined(profileOption),
|
|
74
|
+
resolvedEnv,
|
|
75
|
+
);
|
|
76
|
+
return { env: resolvedEnv, profile: profileName };
|
|
77
|
+
});
|
|
78
|
+
|
|
63
79
|
type CommonK8sCommandOptions = {
|
|
64
80
|
readonly env: Option.Option<string>;
|
|
65
81
|
readonly dryRun: boolean;
|
|
@@ -67,26 +83,36 @@ type CommonK8sCommandOptions = {
|
|
|
67
83
|
readonly profile: Option.Option<string>;
|
|
68
84
|
};
|
|
69
85
|
|
|
86
|
+
const noKubernetesConfigResult = (): CommandResult => ({
|
|
87
|
+
success: false,
|
|
88
|
+
error: "No Kubernetes configuration found",
|
|
89
|
+
hint: "Add a 'kubernetes' section to agent-tools.json5 with clusterId and namespaces.",
|
|
90
|
+
nextCommand:
|
|
91
|
+
"echo '{ kubernetes: { default: { clusterId: \"my-cluster\" } } }' > agent-tools.json5",
|
|
92
|
+
executionTimeMs: 0,
|
|
93
|
+
});
|
|
94
|
+
|
|
70
95
|
const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
|
|
71
96
|
Effect.gen(function* () {
|
|
72
97
|
const config = yield* ConfigService;
|
|
73
|
-
|
|
98
|
+
|
|
99
|
+
// Check for a missing 'kubernetes' section before resolving --env, so that case still
|
|
100
|
+
// surfaces "add a kubernetes section" instead of an env-resolution error (e.g. prod-safety).
|
|
101
|
+
if (!hasKubernetesConfig(config?.kubernetes)) {
|
|
102
|
+
return noKubernetesConfigResult();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { env: resolvedEnv, profile: profileName } = yield* resolveEnvAndProfile(
|
|
106
|
+
options.env,
|
|
107
|
+
options.profile,
|
|
108
|
+
config,
|
|
109
|
+
);
|
|
74
110
|
const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes", profileName);
|
|
75
111
|
|
|
76
112
|
if (!k8sConfig) {
|
|
77
|
-
|
|
78
|
-
success: false,
|
|
79
|
-
error: "No Kubernetes configuration found",
|
|
80
|
-
hint: "Add a 'kubernetes' section to agent-tools.json5 with clusterId and namespaces.",
|
|
81
|
-
nextCommand:
|
|
82
|
-
"echo '{ kubernetes: { default: { clusterId: \"my-cluster\" } } }' > agent-tools.json5",
|
|
83
|
-
executionTimeMs: 0,
|
|
84
|
-
};
|
|
85
|
-
return result;
|
|
113
|
+
return noKubernetesConfigResult();
|
|
86
114
|
}
|
|
87
115
|
|
|
88
|
-
const resolvedEnv = yield* resolveEnv(options.env, config);
|
|
89
|
-
|
|
90
116
|
const k8sService = yield* K8sService;
|
|
91
117
|
const result = yield* k8sService.runKubectl(command, options.dryRun, profileName).pipe(
|
|
92
118
|
Effect.catchTags({
|
|
@@ -94,8 +120,10 @@ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
|
|
|
94
120
|
const errorResult: CommandResult = {
|
|
95
121
|
success: false,
|
|
96
122
|
error: error.message,
|
|
97
|
-
hint:
|
|
98
|
-
|
|
123
|
+
hint:
|
|
124
|
+
error.hint ??
|
|
125
|
+
`Verify cluster ID "${k8sConfig.clusterId}" matches a context in kubectl config. Run: kubectl config get-contexts`,
|
|
126
|
+
...(error.hint ? {} : { nextCommand: "kubectl config get-contexts" }),
|
|
99
127
|
executionTimeMs: 0,
|
|
100
128
|
};
|
|
101
129
|
return Effect.succeed(errorResult);
|
|
@@ -190,8 +218,11 @@ const resolveStructuredNamespace = (
|
|
|
190
218
|
if (explicitNamespace) return explicitNamespace;
|
|
191
219
|
|
|
192
220
|
const config = yield* ConfigService;
|
|
193
|
-
const resolvedEnv = yield*
|
|
194
|
-
|
|
221
|
+
const { env: resolvedEnv, profile: profileName } = yield* resolveEnvAndProfile(
|
|
222
|
+
envOption,
|
|
223
|
+
profileOption,
|
|
224
|
+
config,
|
|
225
|
+
);
|
|
195
226
|
const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes", profileName);
|
|
196
227
|
|
|
197
228
|
if (!k8sConfig) return undefined;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// --profile wins if given; else a kubernetes.<env> section (different cluster per env) wins; else undefined (getToolConfig default).
|
|
2
|
+
export function selectK8sProfile(
|
|
3
|
+
kubernetesSection: Record<string, unknown> | undefined,
|
|
4
|
+
explicitProfile: string | undefined,
|
|
5
|
+
resolvedEnv: string,
|
|
6
|
+
): string | undefined {
|
|
7
|
+
if (explicitProfile) {
|
|
8
|
+
return explicitProfile;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
if (kubernetesSection && Object.hasOwn(kubernetesSection, resolvedEnv)) {
|
|
12
|
+
return resolvedEnv;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function hasKubernetesConfig(
|
|
19
|
+
kubernetesSection: Record<string, unknown> | undefined,
|
|
20
|
+
): boolean {
|
|
21
|
+
return !!kubernetesSection && Object.keys(kubernetesSection).length > 0;
|
|
22
|
+
}
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { K8sConfig } from "#config";
|
|
|
16
16
|
import { collectProcessOutput, quoteShellArg } from "#shared/exec";
|
|
17
17
|
import { resolveEnvTemplate } from "#shared/env-template";
|
|
18
18
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
19
|
+
import { normalizeProfilePrerequisites } from "#shared/prerequisites/config";
|
|
19
20
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
20
21
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
21
22
|
import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
|
|
@@ -275,14 +276,23 @@ export class K8sService extends Context.Service<
|
|
|
275
276
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
276
277
|
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
277
278
|
const apiProbeTimeoutMs = k8sConfig.apiProbeTimeoutMs ?? 2000;
|
|
279
|
+
const { context, kubeconfig } = yield* resolveContext(profile, k8sConfig);
|
|
280
|
+
const reachableWithoutPrerequisites = yield* probeApiReachable(
|
|
281
|
+
context,
|
|
282
|
+
kubeconfig,
|
|
283
|
+
apiProbeTimeoutMs,
|
|
284
|
+
);
|
|
285
|
+
const vpnGated = normalizeProfilePrerequisites(k8sConfig).some(
|
|
286
|
+
(prerequisite) => prerequisite.type === "vpn",
|
|
287
|
+
);
|
|
278
288
|
return yield* runWithProfilePrerequisites(
|
|
279
289
|
config ?? {},
|
|
280
290
|
k8sConfig,
|
|
281
291
|
runPrerequisiteCommand,
|
|
282
292
|
Effect.gen(function* () {
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
293
|
+
const reachable =
|
|
294
|
+
reachableWithoutPrerequisites ||
|
|
295
|
+
(vpnGated && (yield* probeApiReachable(context, kubeconfig, apiProbeTimeoutMs)));
|
|
286
296
|
if (!reachable) {
|
|
287
297
|
return yield* new K8sContextError({
|
|
288
298
|
message: `Kubernetes API server (${k8sConfig.clusterId}) not reachable within ${apiProbeTimeoutMs}ms. VPN likely not connected, or the cluster API is degraded.`,
|
|
@@ -332,6 +342,7 @@ export class K8sService extends Context.Service<
|
|
|
332
342
|
command: fullCommand,
|
|
333
343
|
};
|
|
334
344
|
}),
|
|
345
|
+
{ alreadySatisfied: apiProbeTimeoutMs > 0 && reachableWithoutPrerequisites },
|
|
335
346
|
).pipe(
|
|
336
347
|
Effect.mapError((error) =>
|
|
337
348
|
isPrerequisiteRunError(error)
|
|
@@ -78,32 +78,38 @@ export async function stopWhenIdle(
|
|
|
78
78
|
const deadline = now() + init.disconnectTimeoutMs;
|
|
79
79
|
let evidence = "VPN stop did not produce confirmed disconnected status.";
|
|
80
80
|
try {
|
|
81
|
-
|
|
81
|
+
const remaining = deadline - now();
|
|
82
82
|
if (remaining > 0) {
|
|
83
83
|
const stop = await runCommand("stop", remaining);
|
|
84
|
-
remaining = deadline - now();
|
|
85
84
|
if (stop.exitCode !== 0) {
|
|
86
|
-
evidence =
|
|
85
|
+
evidence = `VPN stop command failed (exit ${stop.exitCode}); ownership is unknown and stop will not be retried.`;
|
|
87
86
|
} else {
|
|
88
|
-
const
|
|
87
|
+
const stillConnected =
|
|
88
|
+
"VPN still reported connected after stop when the disconnect deadline expired.";
|
|
89
|
+
const confirmDisconnected = async (): Promise<true | string> => {
|
|
89
90
|
const statusRemaining = deadline - now();
|
|
90
|
-
if (statusRemaining <= 0)
|
|
91
|
+
if (statusRemaining <= 0) {
|
|
92
|
+
return "VPN stop was dispatched, but the disconnect deadline expired before status could be confirmed.";
|
|
93
|
+
}
|
|
91
94
|
const status = await runCommand("status", statusRemaining);
|
|
92
|
-
remaining = deadline - now();
|
|
93
95
|
const connected = parseVpnStatus(init.driver, status);
|
|
94
96
|
if (connected === false) return true;
|
|
95
|
-
if (connected === undefined
|
|
97
|
+
if (connected === undefined) {
|
|
98
|
+
return status.exitCode === 0
|
|
99
|
+
? "VPN status output after stop was unparseable; ownership is unknown."
|
|
100
|
+
: `VPN status command after stop failed (exit ${status.exitCode}); ownership is unknown.`;
|
|
101
|
+
}
|
|
96
102
|
const sleepRemaining = deadline - now();
|
|
97
|
-
if (sleepRemaining <= 0) return
|
|
103
|
+
if (sleepRemaining <= 0) return stillConnected;
|
|
98
104
|
await sleep(Math.min(250, sleepRemaining));
|
|
99
|
-
|
|
100
|
-
return remaining > 0 && confirmDisconnected();
|
|
105
|
+
return deadline - now() > 0 ? confirmDisconnected() : stillConnected;
|
|
101
106
|
};
|
|
102
|
-
|
|
107
|
+
const confirmed = await confirmDisconnected();
|
|
108
|
+
if (confirmed === true) {
|
|
103
109
|
store.commitStop(guard, true, "VPN stop confirmed disconnected.", now());
|
|
104
110
|
return;
|
|
105
111
|
}
|
|
106
|
-
evidence =
|
|
112
|
+
evidence = confirmed;
|
|
107
113
|
}
|
|
108
114
|
} else {
|
|
109
115
|
evidence = "VPN disconnect deadline expired before a command could safely start.";
|
|
@@ -742,6 +742,8 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
742
742
|
runCommand: PrerequisiteCommandRunner<CommandError>,
|
|
743
743
|
effect: Effect.Effect<A, E, never>,
|
|
744
744
|
options?: {
|
|
745
|
+
/** Caller proved the target is already reachable: skip the VPN entirely (no status, no lease). */
|
|
746
|
+
alreadySatisfied?: boolean;
|
|
745
747
|
tryWithoutPrerequisites?: boolean;
|
|
746
748
|
runGuardianInProcess?: boolean;
|
|
747
749
|
guardianSpawn?: GuardianSpawner;
|
|
@@ -750,7 +752,7 @@ export const runWithProfilePrerequisites = <A, E, CommandError>(
|
|
|
750
752
|
const vpnPrerequisites = normalizeProfilePrerequisites(profile).filter(
|
|
751
753
|
(prerequisite) => prerequisite.type === "vpn",
|
|
752
754
|
);
|
|
753
|
-
if (vpnPrerequisites.length === 0) return effect;
|
|
755
|
+
if (vpnPrerequisites.length === 0 || options?.alreadySatisfied === true) return effect;
|
|
754
756
|
|
|
755
757
|
return Effect.gen(function* () {
|
|
756
758
|
const tryDirect = () => effect.pipe(Effect.result);
|