Skip to content

Commit c1a7e47

Browse files
author
Daan Vinken
committed
fix: pull request finding and S3 plan store hardening
Signed-off-by: Daan Vinken <dvinken@tesla.com>
1 parent 79797f0 commit c1a7e47

File tree

3 files changed

+34
-14
lines changed

3 files changed

+34
-14
lines changed

server/core/runtime/plan_store.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import (
99
)
1010

1111
// PlanStore abstracts plan file persistence.
12-
// In Phase 1, LocalPlanStore wraps current filesystem behavior (Save/Load are no-ops).
13-
// In Phase 2, S3PlanStore will upload after plan and download before apply.
12+
// LocalPlanStore wraps current filesystem behavior (Save/Load are no-ops).
13+
// S3PlanStore uploads after plan and downloads before apply.
1414
type PlanStore interface {
1515
// Save persists a plan file after terraform writes it to planPath.
1616
Save(ctx command.ProjectContext, planPath string) error

server/core/runtime/s3_plan_store.go

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"path/filepath"
1212
"strconv"
1313
"strings"
14+
"time"
1415

1516
"github.com/aws/aws-sdk-go-v2/aws"
1617
awsconfig "github.com/aws/aws-sdk-go-v2/config"
@@ -56,7 +57,10 @@ func NewS3PlanStore(cfg S3PlanStoreConfig, logger logging.SimpleLogging) (*S3Pla
5657
opts = append(opts, awsconfig.WithSharedConfigProfile(cfg.Profile))
5758
}
5859

59-
awsCfg, err := awsconfig.LoadDefaultConfig(context.Background(), opts...)
60+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
61+
defer cancel()
62+
63+
awsCfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
6064
if err != nil {
6165
return nil, fmt.Errorf("loading AWS config: %w", err)
6266
}
@@ -77,7 +81,7 @@ func NewS3PlanStore(cfg S3PlanStoreConfig, logger logging.SimpleLogging) (*S3Pla
7781

7882
client := s3.NewFromConfig(awsCfg, s3Opts...)
7983

80-
if _, err := client.HeadBucket(context.Background(), &s3.HeadBucketInput{
84+
if _, err := client.HeadBucket(ctx, &s3.HeadBucketInput{
8185
Bucket: aws.String(cfg.Bucket),
8286
}); err != nil {
8387
return nil, fmt.Errorf("validating S3 plan store bucket %q: %w", cfg.Bucket, err)
@@ -144,9 +148,16 @@ func (s *S3PlanStore) Load(ctx command.ProjectContext, planPath string) error {
144148
// Reject stale plans: the plan must have been created at the same commit
145149
// the PR currently points to. This prevents applying outdated plans after
146150
// new commits are pushed (e.g. across pod restarts).
147-
// Note: S3 normalizes user-defined metadata keys to title case in responses,
148-
// so "head-commit" (as written in Save) becomes "Head-Commit" here.
149-
planCommit := resp.Metadata["Head-Commit"]
151+
// Note: different S3/S3-compatible implementations may return user-defined
152+
// metadata keys with different casing, so we look up "head-commit"
153+
// case-insensitively.
154+
var planCommit string
155+
for k, v := range resp.Metadata {
156+
if strings.EqualFold(k, "head-commit") {
157+
planCommit = v
158+
break
159+
}
160+
}
150161
if planCommit == "" {
151162
return fmt.Errorf("plan in S3 has no head-commit metadata (key=%s) — run plan again", key)
152163
}
@@ -176,15 +187,15 @@ func (s *S3PlanStore) Load(ctx command.ProjectContext, planPath string) error {
176187
func (s *S3PlanStore) Remove(ctx command.ProjectContext, planPath string) error {
177188
key := s.s3Key(ctx, planPath)
178189

179-
_, err := s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
190+
if _, err := s.client.DeleteObject(context.Background(), &s3.DeleteObjectInput{
180191
Bucket: aws.String(s.bucket),
181192
Key: aws.String(key),
182-
})
183-
if err != nil {
184-
return fmt.Errorf("deleting plan from S3 (key=%s): %w", key, err)
193+
}); err != nil {
194+
s.logger.Warn("failed to delete plan from S3 (key=%s): %v", key, err)
195+
} else {
196+
s.logger.Debug("deleted plan from s3://%s/%s", s.bucket, key)
185197
}
186198

187-
s.logger.Debug("deleted plan from s3://%s/%s", s.bucket, key)
188199
return utils.RemoveIgnoreNonExistent(planPath)
189200
}
190201

@@ -227,6 +238,14 @@ func (s *S3PlanStore) RestorePlans(pullDir, owner, repo string, pullNum int) err
227238

228239
// Strip the prefix up to and including <pullNum>/ to get the relative path.
229240
relPath := strings.TrimPrefix(key, listPrefix)
241+
relPath = filepath.Clean(relPath)
242+
if relPath == "." || relPath == string(os.PathSeparator) {
243+
s.logger.Info("skipping S3 object with empty relative path (key=%s, prefix=%s)", key, listPrefix)
244+
continue
245+
}
246+
if filepath.IsAbs(relPath) || relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) {
247+
return fmt.Errorf("refusing to restore plan outside pull dir (key=%s, relPath=%s)", key, relPath)
248+
}
230249
localPath := filepath.Join(pullDir, relPath)
231250

232251
if err := os.MkdirAll(filepath.Dir(localPath), 0o700); err != nil {
@@ -295,7 +314,8 @@ func (s *S3PlanStore) DeleteForPull(owner, repo string, pullNum int) error {
295314
Bucket: aws.String(s.bucket),
296315
Key: aws.String(key),
297316
}); err != nil {
298-
return fmt.Errorf("deleting plan from S3 (key=%s): %w", key, err)
317+
s.logger.Warn("failed to delete plan from S3 (key=%s): %v", key, err)
318+
continue
299319
}
300320
deleted++
301321
}

server/user_config.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ type UserConfig struct {
9292
PendingApplyStatus bool `mapstructure:"pending-apply-status"`
9393
StatsNamespace string `mapstructure:"stats-namespace"`
9494
PlanDrafts bool `mapstructure:"allow-draft-prs"`
95-
PlanStore string `mapstructure:"plan-store"`
95+
PlanStore string `mapstructure:"plan-store"`
9696
PlanStoreS3Bucket string `mapstructure:"plan-store-s3-bucket"`
9797
PlanStoreS3Endpoint string `mapstructure:"plan-store-s3-endpoint"`
9898
PlanStoreS3ForcePathStyle bool `mapstructure:"plan-store-s3-force-path-style"`

0 commit comments

Comments
 (0)