ec2-instance-running-scheduler 0.1.8 → 0.2.0

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 CHANGED
@@ -4,22 +4,23 @@
4
4
  [![npm](https://img.shields.io/npm/v/ec2-instance-running-scheduler?style=flat-square)](https://www.npmjs.com/package/ec2-instance-running-scheduler)
5
5
  [![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/gammarers-aws-cdk-constructs/ec2-instance-running-scheduler/release.yml?branch=main&label=release&style=flat-square)](https://github.com/gammarers-aws-cdk-constructs/ec2-instance-running-scheduler/actions/workflows/release.yml)
6
6
  [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/gammarers-aws-cdk-constructs/ec2-instance-running-scheduler?sort=semver&style=flat-square)](https://github.com/gammarers-aws-cdk-constructs/ec2-instance-running-scheduler/releases)
7
-
8
7
  [![View on Construct Hub](https://constructs.dev/badge?package=ec2-instance-running-scheduler)](https://constructs.dev/packages/ec2-instance-running-scheduler)
9
8
 
10
- AWS CDK construct that starts and stops EC2 instances on a cron schedule using **EventBridge Scheduler** and a **Durable Execution Lambda**. The handler discovers instances with the **Resource Groups Tagging API**, runs start/stop and **polls until the instance reaches the target state** (durable `step` / `wait`), processes **multiple instances in parallel** (bounded concurrency), and posts **Slack** summary and per-instance thread messages using a secret from **Secrets Manager**. The Lambda emits **structured application logs** (invocation, EC2 transitions, Slack steps, completion) alongside JSON platform logs.
9
+ AWS CDK construct library that starts and stops EC2 instances on a cron schedule using **EventBridge Scheduler** and a **Durable Execution Lambda**. The handler discovers instances with the **Resource Groups Tagging API**, issues start/stop, **polls until each instance reaches a stable target state** (durable `step` / `wait`), processes **multiple instances in parallel** (bounded concurrency), and posts **Slack** summary and per-instance thread messages using a secret from **Secrets Manager**. The Lambda emits **structured application logs** alongside JSON platform logs.
11
10
 
12
11
  ## Features
13
12
 
14
13
  - **Tag-based targeting** – Select EC2 instances by tag key and values (e.g. `Schedule` / `YES`) via `tag:GetResources`.
15
14
  - **EventBridge Scheduler** – Separate cron rules for start and stop, with per-rule timezone (`aws-cdk-lib` `TimeZone`).
16
15
  - **Durable Lambda** – One Lambda with AWS Lambda Durable Execution (`step`, `wait`, `map`, child contexts per instance) for long-running workflows without Step Functions.
17
- - **Stable-state polling** – After start/stop, the function waits and re-describes instances until `running` (start mode) or `stopped` (stop mode), or until a terminal error.
18
- - **Slack notifications** – Required for a successful run: parent message plus threaded updates per instance; credentials come from Secrets Manager (`token` and `channel` JSON). The secret name is passed as **`SLACK_SECRET_NAME`** on the function (from `secrets.slackSecretName`) and validated at runtime via **safe-env-getter**.
19
- - **Structured logging** – The handler uses the durable execution **`ctx.logger`** for traceable JSON application logs (e.g. invocation, describe/start/stop/wait loops, target count, Slack posts, errors before instance state failures).
16
+ - **Stable-state polling** – After start/stop, the function waits (20 seconds between attempts) and re-describes instances until `running` (start mode) or `stopped` (stop mode).
17
+ - **Configurable polling limits** – Per-instance **max loop count** and **max elapsed time** via `resourcePolling` (default: 90 loops / 1800 seconds). Failures use explicit `ResourcePollingFailed:*` messages instead of running until the Durable execution timeout (construct default: 2 hours).
18
+ - **Validated environment variables** – The bundled handler parses required and polling env vars at invocation using strict decimal integer rules; polling limits must be **positive integers** (`> 0`).
19
+ - **Slack notifications** – Parent message plus threaded updates per instance; credentials from Secrets Manager JSON (`token`, `channel`). The construct sets **`SLACK_SECRET_NAME`** on the function.
20
+ - **Structured logging** – Durable execution **`ctx.logger`** for traceable JSON application logs (invocation, describe/start/stop/wait loops, polling limit errors, Slack steps, completion).
20
21
  - **Scheduling toggle** – Enable or disable both schedules without removing the stack (`enableScheduling`).
21
22
  - **Configurable schedules** – Optional cron overrides for start and stop (`minute`, `hour`, `week`, `timezone`); sensible defaults if omitted.
22
- - **IAM and observability** – EC2 and tagging API permissions, Slack secret read grant, **Parameters and Secrets Lambda Extension** (configured on the function for secret access patterns), JSON logging with configurable system/application levels, and a dedicated log group (construct defaults).
23
+ - **IAM and observability** – EC2 and tagging API permissions, Slack secret read grant, **Parameters and Secrets Lambda Extension**, JSON logging, and a dedicated log group (construct defaults).
23
24
 
24
25
  ## Installation
25
26
 
@@ -74,10 +75,14 @@ new EC2InstanceRunningScheduler(stack, 'EC2InstanceRunningScheduler', {
74
75
  week: 'MON-FRI',
75
76
  },
76
77
  enableScheduling: true,
78
+ resourcePolling: {
79
+ maxLoopCount: 120,
80
+ maxElapsedSeconds: 3600,
81
+ },
77
82
  });
78
83
  ```
79
84
 
80
- Use the **stack** `EC2InstanceRunningScheduleStack` when deploying the scheduler as its own stack. It accepts the **same scheduler options** as the construct (plus standard `StackProps` such as `env`).
85
+ Use the **stack** `EC2InstanceRunningScheduleStack` when deploying the scheduler as its own stack. It accepts the same **targeting, schedules, secrets, and enable flag** as the construct (plus standard `StackProps` such as `env`). For **`resourcePolling`**, use the construct directly or extend the stack in your app.
81
86
 
82
87
  ```typescript
83
88
  import * as cdk from 'aws-cdk-lib';
@@ -110,19 +115,32 @@ new EC2InstanceRunningScheduleStack(app, 'EC2InstanceRunningScheduleStack', {
110
115
  });
111
116
  ```
112
117
 
113
- EventBridge Scheduler invokes the Lambda with `Params.TagKey`, `Params.TagValues`, and `Params.Mode` (`Start` or `Stop`); the construct wires this for you. The function environment includes **`SLACK_SECRET_NAME`** set to `secrets.slackSecretName`.
118
+ EventBridge Scheduler invokes the Lambda with `Params.TagKey`, `Params.TagValues`, and `Params.Mode` (`Start` or `Stop`); the construct wires this for you. The function environment includes:
119
+
120
+ | Variable | Source | Purpose |
121
+ |----------|--------|---------|
122
+ | `SLACK_SECRET_NAME` | `secrets.slackSecretName` | Secrets Manager secret for Slack (required) |
123
+ | `PROCESS_RESOURCE_MAX_LOOP_COUNT` | `resourcePolling.maxLoopCount` (default `90`) | Max describe/poll iterations per instance |
124
+ | `PROCESS_RESOURCE_MAX_ELAPSED_SECONDS` | `resourcePolling.maxElapsedSeconds` (default `1800`) | Max wall-clock seconds polling one instance |
125
+
126
+ When you set polling limits via `resourcePolling`, the construct writes them as decimal integer strings. If you override these variables manually, each value must be a **strict decimal integer** (e.g. `"120"` is valid; `"0x10"`, `"3.14"`, or `"10abc"` are rejected) and **greater than zero**. Invalid or missing `SLACK_SECRET_NAME` causes the handler to fail at the start of an invocation.
114
127
 
115
128
  ## Options
116
129
 
117
- These options apply to **`EC2InstanceRunningScheduler`** and to **`EC2InstanceRunningScheduleStack`** (stack props include them alongside `StackProps`).
130
+ ### EC2InstanceRunningScheduler
118
131
 
119
132
  | Option | Type | Required | Description |
120
133
  |--------|------|----------|-------------|
121
134
  | `targetResource` | `TargetResource` | Yes | Tag key and values used to select EC2 instances. |
122
- | `secrets` | `Secrets` | Yes | Identifies the Secrets Manager secret used for Slack (`slackSecretName`). |
135
+ | `secrets` | `Secrets` | Yes | Secrets Manager secret for Slack (`slackSecretName`). |
123
136
  | `startSchedule` | `Schedule` | No | Cron for starting instances (default: `50 7 ? * MON-FRI *` in `Etc/UTC`). |
124
137
  | `stopSchedule` | `Schedule` | No | Cron for stopping instances (default: `5 19 ? * MON-FRI *` in `Etc/UTC`). |
125
138
  | `enableScheduling` | `boolean` | No | Whether both scheduler rules are enabled (default: `true`). |
139
+ | `resourcePolling` | `ResourcePollingLimits` | No | Per-instance polling caps (see below). |
140
+
141
+ ### EC2InstanceRunningScheduleStack
142
+
143
+ Includes `targetResource`, `secrets`, `startSchedule`, `stopSchedule`, `enableScheduling`, and standard `StackProps`. Does **not** expose `resourcePolling`; use `EC2InstanceRunningScheduler` when you need custom polling limits.
126
144
 
127
145
  ### TargetResource
128
146
 
@@ -138,13 +156,22 @@ These options apply to **`EC2InstanceRunningScheduler`** and to **`EC2InstanceRu
138
156
 
139
157
  ### Secrets
140
158
 
141
- - `slackSecretName` – Name of the AWS Secrets Manager secret. The Lambda expects JSON with **`token`** (Slack bot token) and **`channel`** (channel ID or name for `chat.postMessage`). The construct sets **`SLACK_SECRET_NAME`** on the function to this value; the handler reads and validates it at runtime (via **safe-env-getter**).
159
+ - `slackSecretName` – Name of the AWS Secrets Manager secret. The Lambda expects JSON with **`token`** (Slack bot token) and **`channel`** (channel ID or name for `chat.postMessage`).
160
+
161
+ ### ResourcePollingLimits
162
+
163
+ Written to `PROCESS_RESOURCE_MAX_LOOP_COUNT` and `PROCESS_RESOURCE_MAX_ELAPSED_SECONDS` on the running scheduler Lambda.
164
+
165
+ - `maxLoopCount` – Maximum describe/poll loop iterations per instance (default: **90**). Must be a positive integer when set.
166
+ - `maxElapsedSeconds` – Maximum wall-clock seconds spent polling one instance (default: **1800**, 30 minutes). Must be a positive integer when set.
167
+
168
+ When a limit is exceeded during polling, the handler throws an error with prefix `ResourcePollingFailed:` (`MaxLoopCountExceeded`, `MaxElapsedTimeExceeded`, or `UnexpectedInstanceState` for unknown EC2 states).
142
169
 
143
170
  ## Requirements
144
171
 
145
172
  - **Node.js** ≥ 20.0.0 (for developing or synthesizing CDK apps that depend on this package).
146
173
  - **aws-cdk-lib** ^2.232.0 and **constructs** ^10.5.1 (peer dependencies).
147
- - **AWS** – EventBridge Scheduler; Lambda with **Durable Execution** (requires **Node.js 22+** in the deployment region—runtime is the latest Node.js available in the region per the construct), a **live alias**, **Parameters and Secrets Lambda Extension**; EC2 (describe/start/stop); Resource Groups Tagging API; Secrets Manager. The deployed function uses **arm64**, Durable Execution–compatible IAM and settings, and a bundled handler that loads secrets via **aws-lambda-secret-fetcher**.
174
+ - **AWS** – EventBridge Scheduler; Lambda with **Durable Execution** (Node.js **24.x** runtime in the construct; Durable Execution requires a supported Node.js runtime in your region), a **live alias**, **Parameters and Secrets Lambda Extension**; EC2 (`DescribeInstances`, `StartInstances`, `StopInstances`); Resource Groups Tagging API (`tag:GetResources`); Secrets Manager. The deployed function uses **arm64**, Durable Execution IAM policies, a 2-hour Durable execution timeout (construct default), and a bundled handler that loads secrets via **aws-lambda-secret-fetcher**.
148
175
 
149
176
  ## License
150
177
 
@@ -20017,7 +20017,22 @@ var require_lib3 = __commonJS({
20017
20017
  "use strict";
20018
20018
  Object.defineProperty(exports2, "__esModule", { value: true });
20019
20019
  exports2.SafeEnvGetter = exports2.SafeEnvType = exports2.SafeEnvGetterValidationError = exports2.SafeEnvGetterError = void 0;
20020
+ var STRICT_INTEGER_PATTERN = /^-?\d+$/;
20021
+ var parseStrictInteger = (raw) => {
20022
+ const trimmed = raw.trim();
20023
+ if (trimmed === "" || !STRICT_INTEGER_PATTERN.test(trimmed)) {
20024
+ return void 0;
20025
+ }
20026
+ const n = Number(trimmed);
20027
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
20028
+ return void 0;
20029
+ }
20030
+ return n;
20031
+ };
20020
20032
  var SafeEnvGetterError = class extends Error {
20033
+ /**
20034
+ * @param message - Error message.
20035
+ */
20021
20036
  constructor(message) {
20022
20037
  super(message);
20023
20038
  this.name = "SafeEnvGetterError";
@@ -20027,6 +20042,10 @@ var require_lib3 = __commonJS({
20027
20042
  var SafeEnvGetterValidationError = class _SafeEnvGetterValidationError extends SafeEnvGetterError {
20028
20043
  /**
20029
20044
  * Formats validation errors into a human-readable error message.
20045
+ *
20046
+ * @template K - Environment variable key type.
20047
+ * @param errors - Validation error entries to format.
20048
+ * @returns Multi-line summary listing each key and message.
20030
20049
  */
20031
20050
  static format(errors) {
20032
20051
  const lines = errors.map((e) => `- ${e.key}: ${e.message}${e.raw == null ? "" : ` (raw="${e.raw}")`}`);
@@ -20035,6 +20054,8 @@ ${lines.join("\n")}`;
20035
20054
  }
20036
20055
  /**
20037
20056
  * Creates a new validation error from one or more `SafeEnvError` entries.
20057
+ *
20058
+ * @param errors - One or more structured validation errors.
20038
20059
  */
20039
20060
  constructor(errors) {
20040
20061
  const msg = _SafeEnvGetterValidationError.format(errors);
@@ -20046,9 +20067,12 @@ ${lines.join("\n")}`;
20046
20067
  };
20047
20068
  exports2.SafeEnvGetterValidationError = SafeEnvGetterValidationError;
20048
20069
  exports2.SafeEnvType = {
20049
- /** Spec for a string value. */
20070
+ /** Spec for a string value (returned as-is). */
20050
20071
  String: { type: "string" },
20051
- /** Spec for a numeric value. */
20072
+ /**
20073
+ * Spec for a strict decimal integer.
20074
+ * Rejects whitespace-only, hex, `Infinity`, `NaN`, and non-integer forms.
20075
+ */
20052
20076
  Number: { type: "number" },
20053
20077
  /** Spec for a boolean value (1/true/yes/on → true). */
20054
20078
  Boolean: { type: "boolean" },
@@ -20073,8 +20097,8 @@ ${lines.join("\n")}`;
20073
20097
  }
20074
20098
  switch (spec.type) {
20075
20099
  case "number": {
20076
- const n = Number(raw);
20077
- if (Number.isNaN(n)) {
20100
+ const n = parseStrictInteger(raw);
20101
+ if (n === void 0) {
20078
20102
  throw new SafeEnvGetterValidationError([
20079
20103
  { key, message: `Env ${key}: expected number, got "${raw}"`, raw, kind: "invalid_number" }
20080
20104
  ]);
@@ -20113,8 +20137,8 @@ ${lines.join("\n")}`;
20113
20137
  continue;
20114
20138
  }
20115
20139
  if (spec.type === "number") {
20116
- const n = Number(raw);
20117
- if (Number.isNaN(n)) {
20140
+ const n = parseStrictInteger(raw);
20141
+ if (n === void 0) {
20118
20142
  errors.push({ key, message: `Env ${key}: expected number, got "${raw}"`, raw, kind: "invalid_number" });
20119
20143
  continue;
20120
20144
  }
@@ -20145,7 +20169,9 @@ ${lines.join("\n")}`;
20145
20169
  return envs;
20146
20170
  };
20147
20171
  exports2.SafeEnvGetter = {
20172
+ /** Reads and parses a single environment variable. See {@link getEnv}. */
20148
20173
  getEnv,
20174
+ /** Reads and parses multiple environment variables. See {@link getEnvs}. */
20149
20175
  getEnvs
20150
20176
  };
20151
20177
  }
@@ -20167,13 +20193,7 @@ var import_node_console = require("node:console");
20167
20193
  var import_node_util = __toESM(require("node:util"), 1);
20168
20194
  var import_node_url = require("node:url");
20169
20195
  var import_node_path = require("node:path");
20170
- var import_node_url2 = __toESM(require("node:url"), 1);
20171
- var import_node_path2 = __toESM(require("node:path"), 1);
20172
- var import_node_module = __toESM(require("node:module"), 1);
20173
20196
  var import_meta = {};
20174
- var __filename = import_node_url2.default.fileURLToPath(import_meta.url);
20175
- var __dirname = import_node_path2.default.dirname(__filename);
20176
- var require2 = import_node_module.default.createRequire(import_meta.url);
20177
20197
  var DurableExecutionMode;
20178
20198
  (function(DurableExecutionMode2) {
20179
20199
  DurableExecutionMode2["ExecutionMode"] = "ExecutionMode";
@@ -23344,34 +23364,25 @@ var createDefaultLogger = (executionContext) => {
23344
23364
  return new DefaultLogger(executionContext);
23345
23365
  };
23346
23366
  var runtimeDir = process.env.LAMBDA_RUNTIME_DIR || "/var/runtime";
23367
+ var moduleFilePath;
23368
+ if (typeof __filename !== "undefined") {
23369
+ moduleFilePath = __filename;
23370
+ } else if (typeof import_meta?.url === "string") {
23371
+ moduleFilePath = (0, import_node_url.fileURLToPath)(import_meta.url);
23372
+ }
23347
23373
  function isInLambdaRuntime() {
23348
23374
  try {
23349
- if (typeof process !== "undefined" && process.env && false) ;
23350
- let currentFilePath;
23351
- if (typeof __filename !== "undefined") {
23352
- currentFilePath = __filename;
23353
- } else {
23354
- try {
23355
- const getImportMeta = new Function("return import.meta");
23356
- const importMeta = getImportMeta();
23357
- if (importMeta && importMeta.url) {
23358
- currentFilePath = (0, import_node_url.fileURLToPath)(importMeta.url);
23359
- }
23360
- } catch {
23361
- }
23362
- }
23363
- if (currentFilePath) {
23364
- const libraryDirectory = (0, import_node_path.dirname)(currentFilePath);
23365
- return libraryDirectory.startsWith(runtimeDir);
23375
+ if (!moduleFilePath) {
23376
+ return false;
23366
23377
  }
23367
- return false;
23378
+ return (0, import_node_path.dirname)(moduleFilePath).startsWith(runtimeDir);
23368
23379
  } catch {
23369
23380
  return false;
23370
23381
  }
23371
23382
  }
23372
23383
  var isRuntimeBundled = isInLambdaRuntime();
23373
23384
  var SDK_NAME = "aws-durable-execution-sdk-js";
23374
- var baseVersion = "1.1.4";
23385
+ var baseVersion = "1.1.7";
23375
23386
  var SDK_VERSION = isRuntimeBundled ? `${baseVersion}-bundled` : baseVersion;
23376
23387
  var defaultLambdaClient;
23377
23388
  var DurableExecutionApiClient = class {
@@ -23692,10 +23703,73 @@ var import_client_ec2 = require("@aws-sdk/client-ec2");
23692
23703
  var import_client_resource_groups_tagging_api = require("@aws-sdk/client-resource-groups-tagging-api");
23693
23704
  var import_web_api = __toESM(require_dist5());
23694
23705
  var import_aws_lambda_secret_fetcher = __toESM(require_lib2());
23706
+ var import_safe_env_getter2 = __toESM(require_lib3());
23707
+
23708
+ // src/funcs/running-scheduler-polling-env.ts
23695
23709
  var import_safe_env_getter = __toESM(require_lib3());
23696
23710
 
23711
+ // src/funcs/running-scheduler-polling-config.ts
23712
+ var PROCESS_RESOURCE_MAX_LOOP_COUNT_ENV = "PROCESS_RESOURCE_MAX_LOOP_COUNT";
23713
+ var PROCESS_RESOURCE_MAX_ELAPSED_SECONDS_ENV = "PROCESS_RESOURCE_MAX_ELAPSED_SECONDS";
23714
+
23697
23715
  // src/funcs/running-scheduler-predicates.ts
23716
+ var DEFAULT_RESOURCE_POLLING_LIMITS = {
23717
+ maxLoopCount: 90,
23718
+ maxElapsedSeconds: 1800
23719
+ };
23698
23720
  var isDesiredStableState = (mode, currentState) => mode === "Start" && currentState === "running" || mode === "Stop" && currentState === "stopped";
23721
+ var isTransitioningState = (mode, currentState) => mode === "Start" && currentState === "pending" || mode === "Stop" && (currentState === "stopping" || currentState === "shutting-down");
23722
+ var getPollingAbortReason = (loopCount, startedAtMs, nowMs, limits) => {
23723
+ if (loopCount >= limits.maxLoopCount) {
23724
+ return "MaxLoopCountExceeded";
23725
+ }
23726
+ const elapsedSeconds = (nowMs - startedAtMs) / 1e3;
23727
+ if (elapsedSeconds >= limits.maxElapsedSeconds) {
23728
+ return "MaxElapsedTimeExceeded";
23729
+ }
23730
+ return void 0;
23731
+ };
23732
+ var formatResourcePollingFailure = (reason, context) => {
23733
+ const common = `identifier=${context.identifier} mode=${context.mode} currentState=${context.currentState} loopCount=${context.loopCount}`;
23734
+ if (reason === "MaxLoopCountExceeded") {
23735
+ return `ResourcePollingFailed:MaxLoopCountExceeded: ${common} maxLoopCount=${context.limits.maxLoopCount}`;
23736
+ }
23737
+ if (reason === "MaxElapsedTimeExceeded") {
23738
+ return `ResourcePollingFailed:MaxElapsedTimeExceeded: ${common} maxElapsedSeconds=${context.limits.maxElapsedSeconds}`;
23739
+ }
23740
+ return `ResourcePollingFailed:UnexpectedInstanceState: ${common}`;
23741
+ };
23742
+
23743
+ // src/funcs/running-scheduler-polling-env.ts
23744
+ var assertPositiveEnvInt = (key, value) => {
23745
+ if (value <= 0) {
23746
+ const raw = process.env[key];
23747
+ throw new Error(`Invalid ${key}: must be a positive integer (got "${raw ?? ""}")`);
23748
+ }
23749
+ return value;
23750
+ };
23751
+ var parseResourcePollingLimitsFromEnv = () => {
23752
+ const parsed = import_safe_env_getter.SafeEnvGetter.getEnvs({
23753
+ [PROCESS_RESOURCE_MAX_LOOP_COUNT_ENV]: [
23754
+ import_safe_env_getter.SafeEnvType.Number,
23755
+ { default: DEFAULT_RESOURCE_POLLING_LIMITS.maxLoopCount }
23756
+ ],
23757
+ [PROCESS_RESOURCE_MAX_ELAPSED_SECONDS_ENV]: [
23758
+ import_safe_env_getter.SafeEnvType.Number,
23759
+ { default: DEFAULT_RESOURCE_POLLING_LIMITS.maxElapsedSeconds }
23760
+ ]
23761
+ });
23762
+ return {
23763
+ maxLoopCount: assertPositiveEnvInt(
23764
+ PROCESS_RESOURCE_MAX_LOOP_COUNT_ENV,
23765
+ parsed[PROCESS_RESOURCE_MAX_LOOP_COUNT_ENV]
23766
+ ),
23767
+ maxElapsedSeconds: assertPositiveEnvInt(
23768
+ PROCESS_RESOURCE_MAX_ELAPSED_SECONDS_ENV,
23769
+ parsed[PROCESS_RESOURCE_MAX_ELAPSED_SECONDS_ENV]
23770
+ )
23771
+ };
23772
+ };
23699
23773
 
23700
23774
  // src/funcs/running-scheduler.lambda.ts
23701
23775
  var STATE_LIST = [
@@ -23707,7 +23781,7 @@ var getStateDisplay = (current) => {
23707
23781
  const found = STATE_LIST.find((s) => s.state === current);
23708
23782
  return found ? { emoji: found.emoji, name: found.name } : void 0;
23709
23783
  };
23710
- var processOneResource = async (ctx, targetResource, params, resourceIndex) => {
23784
+ var processOneResource = async (ctx, targetResource, params, resourceIndex, pollingLimits) => {
23711
23785
  const parts = targetResource.split("/");
23712
23786
  const identifier = parts[parts.length - 1] ?? "unknown";
23713
23787
  const arnParts = targetResource.split(":");
@@ -23719,11 +23793,36 @@ var processOneResource = async (ctx, targetResource, params, resourceIndex) => {
23719
23793
  identifier,
23720
23794
  region,
23721
23795
  account,
23722
- mode: params.Mode
23796
+ mode: params.Mode,
23797
+ maxLoopCount: pollingLimits.maxLoopCount,
23798
+ maxElapsedSeconds: pollingLimits.maxElapsedSeconds
23723
23799
  });
23800
+ const startedAtMs = await ctx.step(`${stepPrefix}-polling-started-at`, async () => Date.now());
23724
23801
  let loopCount = 0;
23725
23802
  let currentState = "";
23726
23803
  do {
23804
+ const abortReason = await ctx.step(
23805
+ `${stepPrefix}-poll-limit-check-${loopCount}`,
23806
+ async () => getPollingAbortReason(loopCount, startedAtMs, Date.now(), pollingLimits)
23807
+ );
23808
+ if (abortReason) {
23809
+ const message = formatResourcePollingFailure(abortReason, {
23810
+ identifier,
23811
+ mode: params.Mode,
23812
+ currentState: currentState || "unknown",
23813
+ loopCount,
23814
+ limits: pollingLimits
23815
+ });
23816
+ ctx.logger.error("processOneResource: polling limit exceeded", {
23817
+ identifier,
23818
+ abortReason,
23819
+ loopCount,
23820
+ currentState,
23821
+ maxLoopCount: pollingLimits.maxLoopCount,
23822
+ maxElapsedSeconds: pollingLimits.maxElapsedSeconds
23823
+ });
23824
+ throw new Error(message);
23825
+ }
23727
23826
  currentState = await ctx.step(`${stepPrefix}-describe-${loopCount}`, async () => {
23728
23827
  const ec2 = new import_client_ec2.EC2Client({});
23729
23828
  const out = await ec2.send(new import_client_ec2.DescribeInstancesCommand({ InstanceIds: [identifier] }));
@@ -23765,8 +23864,7 @@ var processOneResource = async (ctx, targetResource, params, resourceIndex) => {
23765
23864
  continue;
23766
23865
  }
23767
23866
  if (!isDesiredStableState(mode, currentState)) {
23768
- const transitioning = mode === "Start" && currentState === "pending" || mode === "Stop" && (currentState === "stopping" || currentState === "shutting-down");
23769
- if (transitioning) {
23867
+ if (isTransitioningState(mode, currentState)) {
23770
23868
  ctx.logger.info("processOneResource: wait while transitioning", {
23771
23869
  identifier,
23772
23870
  loopCount,
@@ -23778,13 +23876,20 @@ var processOneResource = async (ctx, targetResource, params, resourceIndex) => {
23778
23876
  loopCount += 1;
23779
23877
  continue;
23780
23878
  }
23879
+ const message = formatResourcePollingFailure("UnexpectedInstanceState", {
23880
+ identifier,
23881
+ mode,
23882
+ currentState,
23883
+ loopCount,
23884
+ limits: pollingLimits
23885
+ });
23781
23886
  ctx.logger.error("processOneResource: unexpected state", {
23782
23887
  identifier,
23783
23888
  mode,
23784
23889
  currentState,
23785
23890
  loopCount
23786
23891
  });
23787
- throw new Error(`instance status fail: mode=${mode} currentState=${currentState}`);
23892
+ throw new Error(message);
23788
23893
  }
23789
23894
  } while (!isDesiredStableState(params.Mode, currentState));
23790
23895
  ctx.logger.info("processOneResource: reached desired stable state", {
@@ -23811,7 +23916,8 @@ var handler = withDurableExecution(async (event, ctx) => {
23811
23916
  if (!params?.TagKey || !params?.TagValues || !params?.Mode) {
23812
23917
  throw new Error("Invalid event: Params.TagKey, Params.TagValues, Params.Mode are required.");
23813
23918
  }
23814
- const slackSecretName = import_safe_env_getter.SafeEnvGetter.getEnv("SLACK_SECRET_NAME");
23919
+ const pollingLimits = parseResourcePollingLimitsFromEnv();
23920
+ const slackSecretName = import_safe_env_getter2.SafeEnvGetter.getEnv("SLACK_SECRET_NAME");
23815
23921
  const slackSecretValue = await ctx.step("fetch-slack-secret", async () => {
23816
23922
  ctx.logger.info("running-scheduler: fetching Slack secret", { secretName: slackSecretName });
23817
23923
  return import_aws_lambda_secret_fetcher.secretFetcher.getSecretValue(slackSecretName);
@@ -23862,7 +23968,7 @@ var handler = withDurableExecution(async (event, ctx) => {
23862
23968
  // ),
23863
23969
  async (mapCtx, targetResource, index) => {
23864
23970
  return mapCtx.runInChildContext(`resource-${index}`, async (childCtx) => {
23865
- const result = await processOneResource(childCtx, targetResource, params, index);
23971
+ const result = await processOneResource(childCtx, targetResource, params, index, pollingLimits);
23866
23972
  childCtx.logger.info("running-scheduler: posting thread Slack message", {
23867
23973
  index,
23868
23974
  identifier: result.identifier,