Skip to content

feat: Plan store object storage#6312

Open
daanvinken wants to merge 5 commits into
runatlantis:mainfrom
daanvinken:plan-store-object-storage
Open

feat: Plan store object storage#6312
daanvinken wants to merge 5 commits into
runatlantis:mainfrom
daanvinken:plan-store-object-storage

Conversation

@daanvinken

@daanvinken daanvinken commented Mar 13, 2026

Copy link
Copy Markdown
Contributor

Updated description:


Description

  • Fixes Proposal: Write plans to S3 #265
  • Add S3-compatible external plan store so Terraform plan files survive container restarts (plan on pod A, pod dies, apply on pod B)
  • Stale plan detection via S3 object metadata: rejects apply if PR head commit changed since plan
  • PR close cleanup deletes all plan objects from S3 via DeleteForPull
  • Fail-fast startup validation via HeadBucket
  • Existing LocalPlanStore behavior unchanged
  • Config consolidated into single --enable-external-stores flag + external_stores block in server-side repo config (per feedback from @jamengual and @pseudomorph)
  • Path traversal protection in RestorePlans via filepath-securejoin

Why

  • Atlantis plan files live on emptyDir and don't survive container restarts
  • Without this, a container restart between plan and apply requires re-planning
  • With multiple replicas, the container that receives the apply webhook may not be the one that ran plan

Testing

  • plan + container restart + apply succeeds (staging cluster, S3-compatible object storage)
  • Stale plan rejected after new commit pushed
  • PR close removes plan objects from S3
  • LocalPlanStore behavior unchanged
  • Running in staging with feat: Redis cluster support. #6295 cherry-picked on top — Atlantis in HA, fully stateless with locks in Redis Cluster and plans in S3

Plan + container restart + apply:

{"level":"info","ts":"...","caller":"server/server.go:666","msg":"initializing S3 plan store (bucket=<bucket>, region=us-east-1)","json":{}}
{"level":"info","ts":"...","caller":"runtime/s3_plan_store.go:124","msg":"uploaded plan to s3://<bucket>/<org>/<repo>/17/default/<dir>/<planfile>","json":{}}
{"level":"info","ts":"...","caller":"events/instrumented_project_command_runner.go:91","msg":"plan success. output available at: https://<github>/<org>/<repo>/pull/17","json":{"repo":"<org>/<repo>","pull":"17"}}

Container restarted, apply triggered:

{"level":"info","ts":"...","caller":"events/project_command_builder.go:815","msg":"pull directory missing, re-cloning repo for apply","json":{"repo":"<org>/<repo>","pull":"17"}}
{"level":"info","ts":"...","caller":"runtime/s3_plan_store.go:247","msg":"restored plan from s3://<bucket>/<org>/<repo>/17/default/<dir>/<planfile> to /atlantis-data/repos/<org>/<repo>/17/default/<dir>/<planfile>","json":{}}
{"level":"info","ts":"...","caller":"runtime/s3_plan_store.go:256","msg":"restored 1 plan(s) from S3 for <org>/<repo>#17","json":{}}
{"level":"info","ts":"...","caller":"runtime/apply_step_runner.go:75","msg":"apply successful, deleting planfile","json":{"repo":"<org>/<repo>","pull":"17"}}

Stale plan rejection:

{"level":"error","ts":"...","caller":"events/instrumented_project_command_runner.go:81","msg":"Error running apply operation: loading plan: plan in S3 has no head-commit metadata (key=<org>/<repo>/17/default/<dir>/<planfile>) — run plan again\n","json":{"repo":"<org>/<repo>","pull":"17"}}

PR close cleanup:

{"level":"info","ts":"...","caller":"events/events_controller.go:605","msg":"Pull request closed, cleaning up...","json":{"repo":"<org>/<repo>","pull":"17"}}
{"level":"info","ts":"...","caller":"runtime/s3_plan_store.go:299","msg":"deleted 1 plan(s) from S3 for <org>/<repo>#17","json":{}}

AI disclosure

This PR was developed with assistance from Claude Opus 4.6 (1M context).

References

Copilot AI review requested due to automatic review settings March 13, 2026 15:41
@github-actions github-actions Bot added dependencies PRs that update a dependency file go Pull requests that update Go code labels Mar 13, 2026
return fmt.Errorf("plan in S3 has no head-commit metadata (key=%s) — run plan again", key)
}
if ctx.Pull.HeadCommit != "" && planCommit != ctx.Pull.HeadCommit {
return fmt.Errorf("plan was created at commit %.8s but PR is now at %.8s — run plan again", planCommit, ctx.Pull.HeadCommit)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

S3PlanStore plans survive restarts by design. Without a check, a plan from commit A gets applied after commit B is pushed. Save stores ctx.Pull.HeadCommit as S3 object metadata; Load rejects if it differs from the current PR head.

LocalPlanStore's Save/Load are no-ops (Terraform does direct disk I/O), so there's no interception point for metadata. With emptyDir this is fine; pod restart wipes plans, forcing re-plan. With a PersistentVolume the same stale plan risk exists, but that's a pre-existing upstream Atlantis gap we didn't introduce.

I'm also ok leaving this out, as it should perhaps be fixed on localPlanStore as well?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@dosubot dosubot Bot added the feature New functionality/enhancement label Mar 13, 2026
@daanvinken daanvinken requested a review from Copilot March 13, 2026 16:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 7 comments.

Comment thread server/core/runtime/s3_plan_store.go Outdated
Comment thread server/core/planstore/s3_plan_store.go
Comment thread server/core/runtime/s3_plan_store.go Outdated
Comment thread server/core/runtime/plan_store.go Outdated
Comment thread server/user_config.go Outdated
Comment thread server/core/planstore/s3_plan_store.go
Comment thread server/events/project_command_builder.go

@adkafka adkafka left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty good to me!

Comment thread server/core/runtime/apply_step_runner.go
Comment thread server/core/runtime/plan_store.go Outdated
Comment thread server/core/planstore/s3_plan_store.go
Comment thread server/core/runtime/s3_plan_store.go Outdated
Comment thread server/core/runtime/s3_plan_store.go Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 21 out of 22 changed files in this pull request and generated 7 comments.

Comment thread server/core/planstore/s3_plan_store.go Outdated
Comment thread server/events/pull_closed_executor.go
Comment thread server/user_config.go Outdated
Comment thread server/core/planstore/s3_plan_store.go
Comment thread server/core/runtime/s3_plan_store.go Outdated
Comment thread server/core/runtime/s3_plan_store.go Outdated
Comment thread server/core/planstore/s3_plan_store.go
@daanvinken daanvinken force-pushed the plan-store-object-storage branch from a7a123d to 9c0b6ed Compare March 16, 2026 10:43
@daanvinken daanvinken changed the title Plan store object storage feat: Plan store object storage Mar 16, 2026
@daanvinken

Copy link
Copy Markdown
Contributor Author

@lukemassa @pseudomorph @GenPage @chenrui333 @nitrocode are you available to have a look here and perhaps at #6295 ? 🙏

@daanvinken daanvinken force-pushed the plan-store-object-storage branch from 9c0b6ed to 47de395 Compare March 16, 2026 12:10
@github-actions github-actions Bot added docs Documentation github-actions labels Mar 16, 2026
@daanvinken daanvinken force-pushed the plan-store-object-storage branch from 47de395 to 8562509 Compare March 16, 2026 12:15
@pseudomorph

Copy link
Copy Markdown
Contributor

I'll try to give this a look in the next day or so.

Comment thread server/core/planstore/s3_plan_store.go Fixed
Comment thread server/core/planstore/s3_plan_store.go Fixed
@jamengual

Copy link
Copy Markdown
Contributor

I see all the new flags and I wonder if we should move to the server side config instead and have only one flag like --enable-s3-store or something like that

@jamengual

Copy link
Copy Markdown
Contributor

also there is a lot of mention of Pods and K8s but I will like to aim this at containers in general ( just a language change), so people do not think this is just only for k8s workloads.

@pseudomorph

Copy link
Copy Markdown
Contributor

I see all the new flags and I wonder if we should move to the server side config instead and have only one flag like --enable-s3-store or something like that

@jamengual - I thought the same thing, though was unsure of what the best structure for this would be. A structured config just for backend stores? Something which mirrors the structured config elements in the repo config?

@jamengual

Copy link
Copy Markdown
Contributor

I see all the new flags and I wonder if we should move to the server side config instead and have only one flag like --enable-s3-store or something like that

@jamengual - I thought the same thing, though was unsure of what the best structure for this would be. A structured config just for backend stores? Something which mirrors the structured config elements in the repo config?

we can do the similar to what we did for autoplan where autoplan settings can be set in the config.json if is passed, so , if enable-external-stores is enabled then it looks into the config.json for the rest of the setting, if not present, then it fails to start

@daanvinken

Copy link
Copy Markdown
Contributor Author

@jamengual @pseudomorph thanks, agreed on both points. Will update pod/k8s language to be platform agnostic.

For the config consolidation, I vouch for a single --enable-external-stores flag, with the store configuration in the server-side config:

external_stores:
  plan_store:
    type: s3
    s3:
      bucket: my-bucket
      region: us-east-1
      # optional (via AWS S3 SDK)
      prefix: atlantis/plans
      endpoint: ""
      force_path_style: false
      profile: ""

Using external_stores as the top-level key leaves room for other store types in the future (e.g. state, logs as discussed at #121) without adding more CLI flags. Startup fails if --enable-external-stores is set but the config block is missing or incomplete.

Does this structure work, or would we e.g. prefer plan_store directly at the top level?

@jamengual

jamengual commented Mar 25, 2026 via email

Copy link
Copy Markdown
Contributor

@lukemassa lukemassa left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall this makes sense to me and is well implemented. I personally use a persistent volume + statefulset so that plans can survive restarts, but in situations where that's not possible this makes sense. It also opens up the door for more HA, which is exciting!

if !os.IsNotExist(err) {
return nil, err
}
if _, isLocal := p.PlanStore.(*runtime.LocalPlanStore); isLocal {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The abstraction of the plan store is somewhat leaking here. I wonder if a better mechanism might be to have the LocalPlanStore unconditionally return an error from RestorePlans() saying "unable to restore plan". It might require pushing some of the clone work into the RestorePlans, but it would make it more clear what's happening here: PlanStore, if possible, is restoring the plan. It would also allow for future implementations to follow the same interface.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Luke! Returning from PTO 05/26 - will have a look

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated. RestorePlans now returns ErrRestoreNotSupported , and both call sites in use errors.Is. Also switched the path traversal guard to filepath-securejoin to satisfy CodeQL.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lukemassa @pseudomorph @jamengual I'm back from PTO and very eager to get these in within the next few weeks. I'm also available on Slack if we need a closer feedback loop.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd think we should put this (and plan_store_test.go, s3_plan_store.go, and s3_plan_store_test.go) up a level in a dir similar to how db/redis/boltdb is structured. Thoughts @lukemassa @jamengual ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point, in-line with patterns like db/redis/boltdb I'm proceeding with that for now.

// Try to restore plans from the store. If the store doesn't support
// restore (e.g. LocalPlanStore), surface the original "not exist" error.
ctx.Log.Info("pull directory missing, attempting to restore plans")
if _, cloneErr := p.WorkingDir.Clone(ctx.Log, ctx.HeadRepo, ctx.Pull, DefaultWorkspace); cloneErr != nil {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This clones only DefaultWorkspace, but p.PlanStore.RestorePlans (L859) restores plans for all workspaces into pullDir//....

PendingPlanFinder.Find (L867) then runs git ls-files . --others in every directory under pullDir and errors if one isn't a git repo.

I'd suggest implementing a method to get a list of workspaces to restore then iterating over each for the clone.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. After RestorePlans, we now scan pullDir for workspace directories that were created by the restore but don't have .git, and clone into each one (cloneMissingWorkspaces). This ensures PendingPlanFinder's git ls-files --others works for all workspaces.

Also added DeletePlanForProject to the PlanStore interface and wired it into DefaultDeleteLockCommand, unlocking a project now deletes its plan from S3 too, preventing plan resurrection.

Finally, DeleteLocksByPull calls DeleteForPull to clean up all plans for the PR.

// Re-clone only if the plan store can recover plans externally.
// LocalPlanStore signals this via ErrRestoreNotSupported; external
// stores (S3) return nil and restore during the apply step.
if errors.Is(p.PlanStore.RestorePlans("", "", "", 0), runtime.ErrRestoreNotSupported) {

@pseudomorph pseudomorph Jun 12, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of checking for error, perhaps give each plan store a method like: SupportsRestore() bool.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the sentinel error pattern per @lukemassa 's earlier suggestion. It keeps the interface small (no extra method) and is idiomatic Go, errors.Is at the call site makes intent clear.

Adding SupportsRestore() bool would require every implementation to satisfy both the method and still handle the error case in RestorePlans.

I'm open to change this either way but explaining myself first.

opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
}

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure this timeout propagates to all the other S3 commands. It's likely that any connectivity issues or otherwise would result in an indefinite hang.

@pseudomorph

Copy link
Copy Markdown
Contributor

Other items to note / questions:

@daanvinken daanvinken force-pushed the plan-store-object-storage branch 2 times, most recently from d12720c to 4f9dec3 Compare June 16, 2026 12:58
Comment thread server/events/project_command_builder.go Fixed
Comment thread server/events/project_command_builder.go Fixed
@daanvinken daanvinken force-pushed the plan-store-object-storage branch 3 times, most recently from eb8cf18 to a8fbf54 Compare June 16, 2026 14:11
@daanvinken

Copy link
Copy Markdown
Contributor Author

Seems that plans can get resurrected from S3 if they are deleted using 'atlantis unlock'. Right now nothing removes the plans from S3. Likely update https://github.com/runatlantis/atlantis/blob/main/server/events/delete_lock_command.go#L30-L46

Added DeletePlanForProject to the PlanStore interface and wired it into DefaultDeleteLockCommand. Single-project unlock now deletes the plan from S3. DeleteLocksByPull calls DeleteForPull to clean up all plans for the PR. Plans can no longer be resurrected after unlock.

How is terraform init handled in cases where we're pulling everything fresh?

terraform init runs as part of the normal plan/apply workflow steps. When a container restart triggers a fresh clone + plan restore, the apply workflow still executes the full step sequence defined in the repo config (init → apply). The re-clone gives us a clean checkout at the right commit, RestorePlans drops the .tfplan files back, and init runs normally before apply reads the plan. No special handling should be needed afaik, as its the same flow as a first-time clone.

Might also want to update: https://github.com/runatlantis/atlantis/blob/main/runatlantis.io/docs/faq.md?plain=1#L17

Pushed

@daanvinken daanvinken force-pushed the plan-store-object-storage branch 3 times, most recently from 3fb9145 to 5c7cf08 Compare June 16, 2026 14:55
@GenPage

GenPage commented Jun 23, 2026

Copy link
Copy Markdown
Member

@pseudomorph @lukemassa for final review

@pseudomorph

Copy link
Copy Markdown
Contributor

Thanks for the updates. A few things still need attention:

Looks like the multi-workspace fix doesn't hold

cloneMissingWorkspaces runs after RestorePlans, but for non-default workspaces this wipes the plans it's trying to preserve. In buildAllProjectCommandsByPlan: we clone default first, RestorePlans writes plans into every workspace dir, then cloneMissingWorkspaces clones into each non-default dir. Since those dirs have no .git, Clone falls through to forceClone → os.RemoveAll, deleting the restored plans. PendingPlanFinder then finds nothing for those workspaces and they're silently dropped from apply-all — so after a restart we'd partial-apply with no error. default only works because it's cloned before restore.

I think we need to clone each workspace first, then restore plans on top. And the recovery test doesn't catch this — it pre-creates default/.git, stubs Clone, and uses a no-op restoreFn, so the real wipe never runs. Could we add a regression test with a non-default workspace and the real Clone? I'd consider this a blocker.

Timeout still unbounded for the list loops

The timeout wrapping is mostly handled, but RestorePlans and DeleteForPull share a single 30s context across the entire paginated loop (every page + every GetObject/DeleteObject). A PR with many plans can blow that budget mid-restore. Can we move the context inside the loop (per-call), or make the budget larger/configurable here?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 28 out of 29 changed files in this pull request and generated 4 comments.

Comment on lines +228 to +234
func (s *S3PlanStore) RestorePlans(pullDir, owner, repo string, pullNum int) error {
if pullDir == "" {
return nil // capability probe: external store supports restore
}
opCtx, opCancel := s3Ctx()
defer opCancel()
// Build the S3 prefix for all plans under this pull request.
Comment on lines +309 to +336
// DeleteForPull removes all plan objects stored under the pull request prefix in S3.
func (s *S3PlanStore) DeleteForPull(owner, repo string, pullNum int) error {
opCtx, opCancel := s3Ctx()
defer opCancel()

prefixParts := []string{}
if s.prefix != "" {
prefixParts = append(prefixParts, s.prefix)
}
prefixParts = append(prefixParts, owner, repo, strconv.Itoa(pullNum))
listPrefix := strings.Join(prefixParts, "/") + "/"

var deleted int
var continuationToken *string
for {
resp, err := s.client.ListObjectsV2(opCtx, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(listPrefix),
ContinuationToken: continuationToken,
})
if err != nil {
return fmt.Errorf("listing plans for deletion (prefix=%s): %w", listPrefix, err)
}

for _, obj := range resp.Contents {
key := aws.ToString(obj.Key)
if _, err := s.client.DeleteObject(opCtx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Comment on lines +958 to +963
// Re-clone only if the plan store can recover plans externally.
// LocalPlanStore signals this via ErrRestoreNotSupported; external
// stores (S3) return nil and restore during the apply step.
if errors.Is(p.PlanStore.RestorePlans("", "", "", 0), runtime.ErrRestoreNotSupported) {
return projCtx, errors.New("no working directory found–did you run plan?")
}
Comment thread server/events/delete_lock_command.go Outdated
Comment on lines +85 to +93
// Clean up external plan store for the entire pull request.
if l.PlanStore != nil && numLocks > 0 {
owner := locks[0].Pull.BaseRepo.Owner
repo := locks[0].Pull.BaseRepo.Name
pullNum := locks[0].Pull.Num
if err := l.PlanStore.DeleteForPull(owner, repo, pullNum); err != nil {
logger.Warn("Failed to delete plans from external store: %s", err)
}
}
Signed-off-by: Daan Vinken <daanvinken@tythus.com>
@daanvinken daanvinken force-pushed the plan-store-object-storage branch from 5c7cf08 to 87e294e Compare June 30, 2026 16:31
cloneMissingWorkspaces ran after RestorePlans, but Clone calls
os.RemoveAll on workspace dirs that don't have .git, wiping the
just-restored plans. Result: silent partial-applies after restart
for any non-default workspace.

Invert the order: add ListWorkspaces to the PlanStore interface,
discover workspaces from the store first, clone each, then
RestorePlans writes into already-initialized dirs.

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
The previous recovery test pre-created default/.git, stubbed Clone,
and used a no-op restoreFn so the wipe never happened. Add a
fakeWorkingDir that mimics forceClone's os.RemoveAll behavior plus a
restoreFn that writes real .tfplan files, then assert that plans for
a non-default workspace survive the Clone + RestorePlans flow.

This test would fail against the original buggy code (Clone(staging)
running after RestorePlans wipes the staging plan).

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
RestorePlans and DeleteForPull shared a single 30s context across
their entire paginated loops. PRs with many plans could exhaust the
budget mid-operation. Move context creation inside the loop so each
S3 call (ListObjectsV2 per page, GetObject / DeleteObject per object)
gets its own 30s deadline.

Also extract downloadObjectTo helper from RestorePlans to keep the
loop body small and tighten resource cleanup with defer.

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
DeleteLocksByPull skipped PlanStore.DeleteForPull when no locks
existed, which would orphan S3 plans if locks were cleaned via
another path (manual cleanup, partial failure). Call DeleteForPull
unconditionally and derive owner/repo from repoFullName instead of
peeking into locks[0] (which is empty in the zero-locks case).

Signed-off-by: Daan Vinken <daanvinken@tythus.com>
@daanvinken

daanvinken commented Jun 30, 2026

Copy link
Copy Markdown
Contributor Author

@pseudomorph

Thanks for tagging along with this and pushing through this PR. Really appreciate the thorough reviews, caught real issues. Fixes are in across these commits:

  • 9dad889f: invert the order: ListWorkspaces first, clone each, then RestorePlans. cloneMissingWorkspaces is gone.
  • e1f8a73b: proper regression test. fakeWorkingDir actually does the RemoveAll + mkdir + git init that real forceClone does, restoreFn writes real plan bytes, and we read back the staging .tfplan at the end to confirm it survived. Would fail against the old code.
  • 447692da: per-call S3 context in RestorePlans and DeleteForPull. Each ListObjectsV2 page and each GetObject/DeleteObject gets its own 30s budget.
  • 77a09248: DeleteForPull now runs unconditionally in DeleteLocksByPull. Owner/repo from repoFullName via strings.Cut, no more peeking at locks[0].

I plan to test this again in my staging env tomorrow.
Update: Tested locally again happy and sad paths, can't share much logs due to sensitivity.

[atlantis-2 atlantis] {"level":"info","ts":"2026-07-01T09:28:19.606Z","caller":"planstore/s3_plan_store.go:144","msg":"uploaded plan to s3://<redacted>-atlantis-plan-store-eng/<redacted>/terraform-atlantis-eng/17/default/on-prem/<redacted>/service/dns/dev/on-prem-<redacted>-service-dns-dev-default.tfplan","json":{}}
blabla restart Atlantis
[atlantis-2 atlantis] {"level":"info","ts":"2026-07-01T09:31:15.949Z","caller":"planstore/s3_plan_store.go:331","msg":"restored plan from s3://<redacted>-atlantis-plan-store-eng/<redacted>/terraform-atlantis-eng/17/default/on-prem/<redacted>/service/dns/dev/on-prem-energ-service-dns-dev-default.tfplan to /atlantis-data/repos/<redacted>/terraform-atlantis-eng/17/default/on-prem/<redacted>/service/dns/dev/on-prem-<redacted>-service-dns-dev-default.tfplan","json":{}}
[atlantis-2 atlantis] {"level":"info","ts":"2026-07-01T09:31:15.949Z","caller":"planstore/s3_plan_store.go:340","msg":"restored 1 plan(s) from S3 for <redacted>/terraform-atlantis-eng#17","json":{}}

I can squash commits once it looks good but this seemed easier to review 👍

Could you run e2e?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies PRs that update a dependency file docs Documentation feature New functionality/enhancement github-actions go Pull requests that update Go code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Proposal: Write plans to S3

8 participants