Skip to content

Commit d09aa0c

Browse files
authored
Fail fast when OIDC credentials cannot be obtained (#31)
The `Configure AWS credentials` step took 82 minutes to fail on a cyber-dojo/dashboard run. Each of the twelve default retry attempts sat on a TCP connect to the STS endpoint that took ~6m45s to time out at the OS level. `connect ETIMEDOUT` on port 443 means no socket was ever opened, so nothing was going to succeed — but the runner was held and billed for 82 minutes, and the caller's `terraform-<name>` concurrency group was held with it, queueing every subsequent apply for that environment behind a job that was already doomed. Bound the step so it dies quickly instead: action-timeout-s: 45 bounds the action as a whole, retries included retry-max-attempts: 3 the default of 12 only helps if attempts are fast timeout-minutes: 2 backstop, independent of the action's behaviour 45 seconds is justified by measurement: across recent successful runs in kosli-dev and cyber-dojo the step took 0-2 seconds, worst case 6, so the timeout sits far above the real p99 and will not fail a slow-but-healthy authentication. `disable-retry` is deliberately not used — STS throttling is genuinely transient and a couple of quick retries is worth having now that their total cost is bounded. Every job also gets a `timeout-minutes` rather than silently inheriting GitHub's 360-minute default, which an 82-minute hang would never have tripped. For the plan/apply job the ceiling follows from `aws_role_duration` rather than from how long Terraform might run: the credentials are static environment variables that are never refreshed, so 20 minutes (the 1200s default) after the credentials step every AWS call fails with ExpiredToken and the work cannot usefully continue. Setup before that step measures 1-35s, so a job consuming its whole credential lifetime lands around 22-23 minutes — hence a default of 30, which is headroom over the real ceiling rather than an arbitrary number. Housekeeping jobs get 5-10. Because that ceiling is coupled to `aws_role_duration`, it is exposed as a `job_timeout_minutes` input on plan.yml, apply.yml and detect-drift.yml rather than hard-coded. A repository with a legitimately long apply raises both together, without needing a change here. Raising only the session duration would let the job be cancelled part-way through an apply, which can leave the state lock held. Beyond base.yml and apply.yml named in the ticket, detect-drift.yml has two `Configure AWS credentials` steps with the same failure mode, so they are covered here too. The values are recorded in the README as the standard for other repositories to adopt.
2 parents d0cd8af + 108dd93 commit d09aa0c

6 files changed

Lines changed: 92 additions & 0 deletions

File tree

.github/workflows/apply.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ on:
5858
description: "When true, pass `--commit <ref>` to the Kosli commands via the COMMIT_ARG environment variable."
5959
default: false
6060
type: boolean
61+
job_timeout_minutes:
62+
description: "Minutes before GitHub cancels the apply job. Raise it together with aws_role_duration when a legitimate apply needs longer; see the README on timeouts."
63+
default: 30
64+
type: number
6165
secrets:
6266
kosli_api_token:
6367
description: "Kosli API token. Required when kosli_template_file is set."
@@ -96,12 +100,14 @@ jobs:
96100
kosli_flow: terraform-apply-${{ inputs.environment }}-${{ github.event.repository.name }}
97101
kosli_cli_version: ${{ inputs.kosli_cli_version }}
98102
pass_commit_to_kosli_commands: ${{ inputs.pass_commit_to_kosli_commands }}
103+
job_timeout_minutes: ${{ inputs.job_timeout_minutes }}
99104
secrets:
100105
kosli_api_token: ${{ secrets.kosli_api_token }}
101106

102107
reset-drift-detection:
103108
needs: apply
104109
runs-on: ubuntu-latest
110+
timeout-minutes: 10
105111
permissions:
106112
id-token: write
107113
contents: read
@@ -121,12 +127,15 @@ jobs:
121127
ref: ${{ inputs.ref }}
122128

123129
- name: Configure AWS credentials
130+
timeout-minutes: 2
124131
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
125132
with:
126133
role-to-assume: ${{ inputs.aws_role_arn }}
127134
aws-region: ${{ inputs.aws_region }}
128135
role-duration-seconds: ${{ inputs.aws_role_duration }}
129136
role-session-name: ${{ github.event.repository.name }}
137+
action-timeout-s: 45
138+
retry-max-attempts: 3
130139

131140
- name: Compute state bucket name
132141
run: |

.github/workflows/base.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ on:
2121
tf_apply:
2222
default: false
2323
type: boolean
24+
job_timeout_minutes:
25+
description: "Minutes before GitHub cancels the plan/apply job. The default follows aws_role_duration: the OIDC credentials are static environment variables that are never refreshed, so AWS calls start failing with ExpiredToken 1200s (20 min) after the credentials step and a longer ceiling would buy nothing. Raise both together if a legitimate apply needs longer."
26+
default: 30
27+
type: number
2428
environment:
2529
required: true
2630
type: string
@@ -74,6 +78,7 @@ on:
7478
jobs:
7579
terraform:
7680
runs-on: ubuntu-latest
81+
timeout-minutes: ${{ inputs.job_timeout_minutes }}
7782
env:
7883
AWS_DEFAULT_REGION: ${{ inputs.aws_region }}
7984
environment: ${{ inputs.environment }}
@@ -121,12 +126,15 @@ jobs:
121126
run: terraform fmt --recursive -check
122127

123128
- name: Configure AWS credentials
129+
timeout-minutes: 2
124130
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
125131
with:
126132
role-to-assume: ${{ inputs.aws_role_arn }}
127133
aws-region: ${{ inputs.aws_region }}
128134
role-duration-seconds: ${{ inputs.aws_role_duration }}
129135
role-session-name: ${{ github.event.repository.name }}
136+
action-timeout-s: 45
137+
retry-max-attempts: 3
130138

131139
- name: Setup Kosli CLI
132140
if: inputs.kosli_template_file != ''

.github/workflows/detect-drift.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ on:
2525
description: "Extra environment variables, one KEY=VALUE per line, exported before the drift plan. To set Terraform variable `foo`, use `TF_VAR_foo=...`. Single-line values only. Note: for per-build values (e.g. an image tag) give the variable a default in variables.tf instead, otherwise drift detection reports false drift."
2626
default: ""
2727
type: string
28+
job_timeout_minutes:
29+
description: "Minutes before GitHub cancels the drift plan job. Raise it together with aws_role_duration when a legitimate plan needs longer; see the README on timeouts."
30+
default: 30
31+
type: number
2832

2933
concurrency:
3034
group: detect-drift-${{ github.repository }}-${{ inputs.environment }}
@@ -33,6 +37,7 @@ concurrency:
3337
jobs:
3438
fetch-baseline:
3539
runs-on: ubuntu-latest
40+
timeout-minutes: 10
3641
permissions:
3742
id-token: write
3843
contents: read
@@ -41,12 +46,15 @@ jobs:
4146
drift: ${{ steps.read.outputs.drift }}
4247
steps:
4348
- name: Configure AWS credentials
49+
timeout-minutes: 2
4450
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
4551
with:
4652
role-to-assume: ${{ inputs.aws_role_arn }}
4753
aws-region: ${{ inputs.aws_region }}
4854
role-duration-seconds: ${{ inputs.aws_role_duration }}
4955
role-session-name: ${{ github.event.repository.name }}
56+
action-timeout-s: 45
57+
retry-max-attempts: 3
5058

5159
- name: Compute state bucket name
5260
run: |
@@ -98,22 +106,27 @@ jobs:
98106
ref: ${{ needs.fetch-baseline.outputs.sha }}
99107
tf_apply: false
100108
tf_vars: ${{ inputs.tf_vars }}
109+
job_timeout_minutes: ${{ inputs.job_timeout_minutes }}
101110

102111
flag-drift:
103112
needs: [fetch-baseline, plan]
104113
if: needs.plan.outputs.has_changes == 'true'
105114
runs-on: ubuntu-latest
115+
timeout-minutes: 10
106116
permissions:
107117
id-token: write
108118
contents: read
109119
steps:
110120
- name: Configure AWS credentials
121+
timeout-minutes: 2
111122
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
112123
with:
113124
role-to-assume: ${{ inputs.aws_role_arn }}
114125
aws-region: ${{ inputs.aws_region }}
115126
role-duration-seconds: ${{ inputs.aws_role_duration }}
116127
role-session-name: ${{ github.event.repository.name }}
128+
action-timeout-s: 45
129+
retry-max-attempts: 3
117130

118131
- name: Compute state bucket name
119132
run: |
@@ -142,6 +155,7 @@ jobs:
142155
needs: [fetch-baseline, plan]
143156
if: needs.plan.outputs.has_changes == 'false'
144157
runs-on: ubuntu-latest
158+
timeout-minutes: 5
145159
steps:
146160
- name: No-drift summary
147161
run: |

.github/workflows/plan.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ on:
4646
kosli_cli_version:
4747
default: "latest"
4848
type: string
49+
job_timeout_minutes:
50+
description: "Minutes before GitHub cancels the plan job. Raise it together with aws_role_duration when a legitimate plan needs longer; see the README on timeouts."
51+
default: 30
52+
type: number
4953
secrets:
5054
kosli_api_token:
5155
description: "Kosli API token. Required when kosli_template_file is set."
@@ -80,5 +84,6 @@ jobs:
8084
kosli_org: ${{ inputs.kosli_org }}
8185
kosli_flow: terraform-plan-${{ inputs.environment }}-${{ github.event.repository.name }}
8286
kosli_cli_version: ${{ inputs.kosli_cli_version }}
87+
job_timeout_minutes: ${{ inputs.job_timeout_minutes }}
8388
secrets:
8489
kosli_api_token: ${{ secrets.kosli_api_token }}

.github/workflows/test.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ on:
99
jobs:
1010
test:
1111
runs-on: ubuntu-latest
12+
timeout-minutes: 10
1213
steps:
1314
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
1415
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ Both `plan.yml` and `apply.yml` accept the same core inputs:
100100
| `working_directory` | no | `./` | Directory containing Terraform config |
101101
| `tf_version` | no | `1.14.6` | Terraform version to install |
102102
| `tf_vars` | no | `""` | Extra env vars (one `KEY=VALUE` per line) exported before plan/apply; see [Supplying Terraform variables](#supplying-terraform-variables) |
103+
| `job_timeout_minutes` | no | `30` | Minutes before GitHub cancels the plan/apply job; see [Timeouts](#timeouts) |
103104

104105
Plus, for opting into Kosli attestation (see [Kosli attestation](#kosli-attestation) below):
105106

@@ -169,6 +170,60 @@ image tag, which changes every run.
169170
| `kosli_api_token` | if `kosli_template_file` is set | Kosli API token for the attest steps. |
170171
| `kosli_github_token` | no (only `apply.yml`) | GitHub token used by `kosli attest pr github` to look up pull requests. When omitted, the pull-request attestation step is skipped. Typically passed as `${{ secrets.GITHUB_TOKEN }}` — in which case the **calling job must also declare `pull-requests: read`** in its `permissions:` block (see example below), otherwise the attestation step will fail with `Resource not accessible by integration`. |
171172

173+
### Timeouts
174+
175+
Every job carries a `timeout-minutes` rather than inheriting GitHub's 360-minute default, and the
176+
OIDC credential step is bounded so that an unreachable STS endpoint fails in under a minute instead
177+
of holding a runner — and the environment's `concurrency` group — for over an hour:
178+
179+
```yaml
180+
- name: Configure AWS credentials
181+
timeout-minutes: 2
182+
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
183+
with:
184+
# ...
185+
action-timeout-s: 45
186+
retry-max-attempts: 3
187+
```
188+
189+
These are the standard values for **any** Kosli workflow using
190+
`aws-actions/configure-aws-credentials`, not just the ones here. The reasoning:
191+
192+
| Setting | Value | Why |
193+
|---|---|---|
194+
| `action-timeout-s` | `45` | Bounds the action as a whole, retries included. Across recent successful runs in `kosli-dev` and `cyber-dojo` the step took 0–2s, worst case 6s, so 45s is far above the real p99 and will not fail a slow-but-healthy authentication. |
195+
| `retry-max-attempts` | `3` | The default is 12. STS throttling is genuinely transient and worth retrying, but 12 attempts is only useful if each attempt is fast — which is exactly what fails to hold when the endpoint is unreachable. |
196+
| `timeout-minutes` (step) | `2` | A backstop that holds regardless of how the action behaves or what a future version changes. |
197+
| `timeout-minutes` (job) | `30` plan/apply, `5`–`10` housekeeping | Bounded by `aws_role_duration`, not by how long Terraform might take — see below. |
198+
199+
`disable-retry` is deliberately **not** used: a couple of quick retries is worth having, and
200+
`action-timeout-s` already bounds the total cost of them.
201+
202+
The plan/apply job's default of 30 minutes is **derived from `aws_role_duration`**, which defaults
203+
to `1200` (20 minutes). The credentials the OIDC step exports are static environment variables and
204+
are never refreshed, so 20 minutes after that step every AWS call starts failing with
205+
`ExpiredToken` — a longer job ceiling would buy nothing, because the work cannot usefully continue.
206+
Measured against real runs, setup before the credentials step takes 1–35s, so a job that consumes
207+
its entire credential lifetime lands around 22–23 minutes; 30 leaves headroom without being
208+
arbitrary.
209+
210+
The two values are coupled, so **raise `job_timeout_minutes` and `aws_role_duration` together** when
211+
a repository has a legitimately long apply. Raising only the session duration lets the job be
212+
cancelled part-way through an `apply`, which can leave the state lock held — a worse outcome than a
213+
slow run. Raising only the job ceiling buys time in which every AWS call fails:
214+
215+
```yaml
216+
with:
217+
aws_role_duration: "3600" # 60 min session
218+
job_timeout_minutes: 70 # 60 + setup + headroom
219+
```
220+
221+
The role's own maximum session duration is the hard limit on `aws_role_duration`; if a longer
222+
session is refused, that maximum needs raising on the IAM role first.
223+
224+
A job that fails in 45 seconds can be re-run for nothing. A job that hangs for 82 minutes blocks
225+
every apply queued behind it.
226+
172227
### What it does
173228

174229
**Plan** (`plan.yml`):

0 commit comments

Comments
 (0)