├── .gitignore ├── .dockerignore ├── CODEOWNERS ├── .github ├── renovate.json └── workflows │ └── build.yml ├── internal ├── notification │ ├── slackwebhook_test.go │ ├── notification_test.go │ ├── workflow_test.go │ ├── notification.go │ ├── zap.go │ ├── multi.go │ ├── workflow.go │ └── slackwebhook.go ├── processedcache │ ├── dynamodb_test.go │ ├── cache_test.go │ ├── cache.go │ └── dynamodb.go ├── testhelper │ └── test_helpers.go ├── atlantisgithub │ └── atlantisgithub.go ├── atlantis │ ├── config.go │ ├── config_test.go │ ├── client_test.go │ └── client.go ├── terraform │ ├── terraform_test.go │ └── terraform.go └── drifter │ └── drifter.go ├── action.yml ├── Dockerfile ├── example.env ├── cmd └── atlantis-drift-detection │ └── main.go ├── go.mod ├── README.md ├── LICENSE └── go.sum /.gitignore: -------------------------------------------------------------------------------- 1 | /.env 2 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /.env 2 | /.git -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @cresta/infrastructure-squad 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>cresta/renovate-config", 5 | "local>cresta/renovate-config:automerge-minor" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /internal/notification/slackwebhook_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "github.com/cresta/atlantis-drift-detection/internal/testhelper" 5 | "net/http" 6 | "testing" 7 | ) 8 | 9 | func TestSlackWebhook_ExtraWorkspaceInRemote(t *testing.T) { 10 | testhelper.ReadEnvFile(t, "../../") 11 | wh := NewSlackWebhook(testhelper.EnvOrSkip(t, "SLACK_WEBHOOK_URL"), http.DefaultClient) 12 | genericNotificationTest(t, wh) 13 | } 14 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Atlantis terraform drift detection' 2 | description: 'Some automation to detect drift inside atlantis via the remote /plan endpoint' 3 | branding: 4 | icon: 'activity' 5 | color: 'blue' 6 | runs: 7 | using: 'docker' 8 | # TODO: Figure out a way to auto update this. It's very useful for speeding up the action to not have it build the 9 | # container each run 10 | image: 'docker://ghcr.io/cresta/atlantis-drift-detection:v1' -------------------------------------------------------------------------------- /internal/processedcache/dynamodb_test.go: -------------------------------------------------------------------------------- 1 | package processedcache 2 | 3 | import ( 4 | "context" 5 | "github.com/cresta/atlantis-drift-detection/internal/testhelper" 6 | "github.com/stretchr/testify/require" 7 | "testing" 8 | ) 9 | 10 | func makeTestClient(t *testing.T) *DynamoDB { 11 | testhelper.ReadEnvFile(t, "../../") 12 | client, err := NewDynamoDB(context.Background(), testhelper.EnvOrSkip(t, "DYNAMODB_TABLE")) 13 | require.NoError(t, err) 14 | return client 15 | } 16 | 17 | func TestDynamoDB(t *testing.T) { 18 | GenericCacheWorkflowTest(t, makeTestClient(t)) 19 | } 20 | -------------------------------------------------------------------------------- /internal/notification/notification_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | ) 8 | 9 | func genericNotificationTest(t *testing.T, notification Notification) { 10 | ctx := context.Background() 11 | require.NoError(t, notification.ExtraWorkspaceInRemote(ctx, "genericNotificationTest/ExtraWorkspaceInRemote", "test-workspace")) 12 | require.NoError(t, notification.MissingWorkspaceInRemote(ctx, "genericNotificationTest/MissingWorkspaceInRemote", "test-workspace")) 13 | require.NoError(t, notification.PlanDrift(ctx, "genericNotificationTest/PlanDrift", "test-workspace")) 14 | } 15 | -------------------------------------------------------------------------------- /internal/notification/workflow_test.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "github.com/cresta/atlantis-drift-detection/internal/testhelper" 6 | "github.com/cresta/gogithub" 7 | "go.uber.org/zap/zaptest" 8 | "testing" 9 | ) 10 | 11 | func TestNewWorkflow(t *testing.T) { 12 | testhelper.ReadEnvFile(t, "../../") 13 | logger := zaptest.NewLogger(t) 14 | ghClient, err := gogithub.NewGQLClient(context.Background(), logger, nil) 15 | if err != nil { 16 | t.Skip("skipping test because we can't create a github client") 17 | } 18 | wh := NewWorkflow(ghClient, testhelper.EnvOrSkip(t, "WORKFLOW_OWNER"), testhelper.EnvOrSkip(t, "WORKFLOW_REPO"), testhelper.EnvOrSkip(t, "WORKFLOW_ID"), testhelper.EnvOrSkip(t, "WORKFLOW_REF")) 19 | genericNotificationTest(t, wh) 20 | } 21 | -------------------------------------------------------------------------------- /internal/notification/notification.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type State int 8 | 9 | const ( 10 | StateUnknown State = iota 11 | StateNoDrift 12 | StateExtraWorkspaceInRemote 13 | StateMissingWorkspaceInRemote 14 | ) 15 | 16 | type Location struct { 17 | Directory string 18 | Workspace string 19 | } 20 | 21 | type Notification interface { 22 | ExtraWorkspaceInRemote(ctx context.Context, dir string, workspace string) error 23 | MissingWorkspaceInRemote(ctx context.Context, dir string, workspace string) error 24 | PlanDrift(ctx context.Context, dir string, workspace string) error 25 | // TemporaryError is called when an error occurs but we can't really tell what it means 26 | TemporaryError(ctx context.Context, dir string, workspace string, err error) error 27 | } 28 | -------------------------------------------------------------------------------- /internal/testhelper/test_helpers.go: -------------------------------------------------------------------------------- 1 | package testhelper 2 | 3 | import ( 4 | "github.com/joho/godotenv" 5 | "github.com/stretchr/testify/require" 6 | "os" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func ReadEnvFile(t *testing.T, rootDir string) { 12 | expectedEnvPath := filepath.Join(rootDir, ".env") 13 | if _, err := os.Stat(expectedEnvPath); err != nil { 14 | t.Logf("no .env file found at %s", expectedEnvPath) 15 | return 16 | } 17 | envs, err := godotenv.Read(expectedEnvPath) 18 | require.NoError(t, err) 19 | for k, v := range envs { 20 | if os.Getenv(k) == "" { 21 | t.Setenv(k, v) 22 | } 23 | } 24 | } 25 | 26 | func EnvOrSkip(t *testing.T, env string) string { 27 | body := os.Getenv(env) 28 | if body == "" { 29 | t.Skip(env + " not set, skipping test") 30 | } 31 | return body 32 | } 33 | -------------------------------------------------------------------------------- /internal/atlantisgithub/atlantisgithub.go: -------------------------------------------------------------------------------- 1 | package atlantisgithub 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/cresta/gogit" 7 | "github.com/cresta/gogithub" 8 | ) 9 | 10 | func CheckOutTerraformRepo(ctx context.Context, gitHubClient gogithub.GitHub, cloner *gogit.Cloner, repo string) (*gogit.Repository, error) { 11 | token, err := gitHubClient.GetAccessToken(ctx) 12 | if err != nil { 13 | return nil, fmt.Errorf("failed to get access token: %w", err) 14 | } 15 | // https://docs.github.com/en/developers/apps/building-github-apps/authenticating-with-github-apps#http-based-git-access-by-an-installation 16 | githubRepoURL := fmt.Sprintf("https://x-access-token:%s@github.com/%s.git", token, repo) 17 | repository, err := cloner.Clone(ctx, githubRepoURL) 18 | if err != nil { 19 | return nil, fmt.Errorf("failed to clone repo: %w", err) 20 | } 21 | return repository, nil 22 | } 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM public.ecr.aws/docker/library/golang:1.24.5 as build 2 | 3 | WORKDIR /app 4 | 5 | COPY go.mod . 6 | COPY go.sum . 7 | RUN go mod download 8 | COPY . . 9 | 10 | RUN CGO_ENABLED=0 GOOS=linux go build -a -tags netgo -ldflags '-w' -o /atlantis-drift-detection ./cmd/atlantis-drift-detection/main.go 11 | 12 | FROM public.ecr.aws/docker/library/ubuntu:24.04 13 | 14 | RUN apt-get update \ 15 | && apt-get install -y wget unzip git \ 16 | && rm -rf /var/lib/apt/lists/* 17 | 18 | 19 | ARG TERRAFORM_VERSION=1.7.4 20 | RUN wget --quiet https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ 21 | && unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip \ 22 | && mv terraform /usr/bin \ 23 | && rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip 24 | 25 | COPY --from=build /atlantis-drift-detection /atlantis-drift-detection 26 | 27 | ENTRYPOINT ["/atlantis-drift-detection"] 28 | -------------------------------------------------------------------------------- /internal/processedcache/cache_test.go: -------------------------------------------------------------------------------- 1 | package processedcache 2 | 3 | import ( 4 | "context" 5 | "github.com/stretchr/testify/require" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func GenericCacheWorkflowTest(t *testing.T, cache ProcessedCache) { 11 | currentTime := time.Now().Round(time.Millisecond) 12 | testKey := &ConsiderDriftChecked{ 13 | Dir: "test" + currentTime.String(), 14 | Workspace: "test", 15 | } 16 | testValue := &DriftCheckValue{ 17 | Error: "test", 18 | Drift: true, 19 | When: currentTime, 20 | } 21 | ctx := context.Background() 22 | item, err := cache.GetDriftCheckResult(ctx, testKey) 23 | require.NoError(t, err) 24 | require.Nil(t, item) 25 | err = cache.StoreDriftCheckResult(ctx, testKey, testValue) 26 | require.NoError(t, err) 27 | item, err = cache.GetDriftCheckResult(ctx, testKey) 28 | require.NoError(t, err) 29 | require.NotNil(t, item) 30 | require.Equal(t, testValue, item) 31 | err = cache.DeleteDriftCheckResult(ctx, testKey) 32 | require.NoError(t, err) 33 | item, err = cache.GetDriftCheckResult(ctx, testKey) 34 | require.NoError(t, err) 35 | require.Nil(t, item) 36 | } 37 | -------------------------------------------------------------------------------- /internal/notification/zap.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | 6 | "go.uber.org/zap" 7 | ) 8 | 9 | type Zap struct { 10 | Logger *zap.Logger 11 | } 12 | 13 | func (I *Zap) TemporaryError(_ context.Context, dir string, workspace string, err error) error { 14 | I.Logger.Error("Unknown error in remote", zap.String("dir", dir), zap.String("workspace", workspace), zap.Error(err)) 15 | return nil 16 | } 17 | 18 | func (I *Zap) PlanDrift(_ context.Context, dir string, workspace string) error { 19 | I.Logger.Info("Plan has drifted", zap.String("dir", dir), zap.String("workspace", workspace)) 20 | return nil 21 | } 22 | 23 | func (I *Zap) ExtraWorkspaceInRemote(_ context.Context, dir string, workspace string) error { 24 | I.Logger.Info("Extra workspace in remote", zap.String("dir", dir), zap.String("workspace", workspace)) 25 | return nil 26 | } 27 | 28 | func (I *Zap) MissingWorkspaceInRemote(_ context.Context, dir string, workspace string) error { 29 | I.Logger.Info("Missing workspace in remote", zap.String("dir", dir), zap.String("workspace", workspace)) 30 | return nil 31 | } 32 | 33 | var _ Notification = &Zap{} 34 | -------------------------------------------------------------------------------- /example.env: -------------------------------------------------------------------------------- 1 | # Just the protocol and hostname of atlantis 2 | ATLANTIS_HOST=https://atlantis.company.com 3 | # Your atlantis token 4 | ATLANTIS_TOKEN=PRIVATE_TOKEN 5 | # A plan that is ok 6 | PLAN_SUMMARY_OK='{"Repo": "company/terraform", "Ref": "master", "Type": "Github", "Dir": "environments/aws/env1", "Workspace": "account1"}' 7 | # A plan that you expect to have changes 8 | PLAN_SUMMARY_CHANGES='{"Repo": "company/terraform", "Ref": "master", "Type": "Github", "Dir": "environments/aws/env1", "Workspace": "account1"}' 9 | # A plan you expect to already be locked 10 | PLAN_SUMMARY_LOCK='{"Repo": "company/terraform", "Ref": "master", "Type": "Github", "Dir": "environments/aws/env1", "Workspace": "account1"}' 11 | # Local directory to terraform 12 | TERRAFORM_DIR=/home/USER/GolandProjects/terraform 13 | # A sub directory in TERRAFORM_DIR that has terraform files 14 | TERRAFORM_SUBDIR=environments/aws/env1 15 | # Optional: A directory to whitelist (will only run for this directory) 16 | DIRECTORY_WHITELIST=environments/aws/lambda/helloworld 17 | # Optional: A slack webhook URL to get notifications 18 | SLACK_WEBHOOK_URL=https://hooks.slack.com/services/X/Y/Z 19 | # Your terraform repository 20 | REPO=company/terraform 21 | # Optional: (but sometimes useful) 22 | AWS_PROFILE=extra-prfiles 23 | -------------------------------------------------------------------------------- /internal/notification/multi.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import "context" 4 | 5 | type Multi struct { 6 | Notifications []Notification 7 | } 8 | 9 | func (m *Multi) TemporaryError(ctx context.Context, dir string, workspace string, err error) error { 10 | for _, n := range m.Notifications { 11 | if err := n.TemporaryError(ctx, dir, workspace, err); err != nil { 12 | return err 13 | } 14 | } 15 | return nil 16 | } 17 | 18 | func (m *Multi) ExtraWorkspaceInRemote(ctx context.Context, dir string, workspace string) error { 19 | for _, n := range m.Notifications { 20 | if err := n.ExtraWorkspaceInRemote(ctx, dir, workspace); err != nil { 21 | return err 22 | } 23 | } 24 | return nil 25 | } 26 | 27 | func (m *Multi) MissingWorkspaceInRemote(ctx context.Context, dir string, workspace string) error { 28 | for _, n := range m.Notifications { 29 | if err := n.MissingWorkspaceInRemote(ctx, dir, workspace); err != nil { 30 | return err 31 | } 32 | } 33 | return nil 34 | } 35 | 36 | func (m *Multi) PlanDrift(ctx context.Context, dir string, workspace string) error { 37 | for _, n := range m.Notifications { 38 | if err := n.PlanDrift(ctx, dir, workspace); err != nil { 39 | return err 40 | } 41 | } 42 | return nil 43 | } 44 | 45 | var _ Notification = &Multi{} 46 | -------------------------------------------------------------------------------- /internal/atlantis/config.go: -------------------------------------------------------------------------------- 1 | package atlantis 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "sort" 8 | "strings" 9 | 10 | "github.com/runatlantis/atlantis/server/core/config/valid" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | type DirectoriesWithWorkspaces map[string][]string 15 | 16 | func (d DirectoriesWithWorkspaces) SortedKeys() []string { 17 | keys := make([]string, 0, len(d)) 18 | for k := range d { 19 | keys = append(keys, k) 20 | } 21 | sort.Strings(keys) 22 | return keys 23 | } 24 | 25 | func ConfigToWorkspaces(cfg *SimpleAtlantisConfig) DirectoriesWithWorkspaces { 26 | workspaces := make(DirectoriesWithWorkspaces) 27 | for _, p := range cfg.Projects { 28 | if _, exists := workspaces[p.Dir]; !exists { 29 | workspaces[p.Dir] = []string{} 30 | } 31 | workspaces[p.Dir] = append(workspaces[p.Dir], p.Workspace) 32 | } 33 | return workspaces 34 | } 35 | 36 | type SimpleAtlantisConfig struct { 37 | Version int 38 | Projects []valid.Project 39 | } 40 | 41 | func ParseRepoConfig(body string) (*SimpleAtlantisConfig, error) { 42 | var ret SimpleAtlantisConfig 43 | if err := yaml.NewDecoder(strings.NewReader(body)).Decode(&ret); err != nil { 44 | return nil, fmt.Errorf("error parsing config: %s", err) 45 | } 46 | return &ret, nil 47 | } 48 | 49 | func ParseRepoConfigFromDir(dir string) (*SimpleAtlantisConfig, error) { 50 | filename := filepath.Join(dir, "atlantis.yaml") 51 | body, err := os.ReadFile(filename) 52 | if err != nil { 53 | return nil, fmt.Errorf("error reading config: %s", err) 54 | } 55 | return ParseRepoConfig(string(body)) 56 | } 57 | -------------------------------------------------------------------------------- /internal/terraform/terraform_test.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "context" 5 | "github.com/cresta/atlantis-drift-detection/internal/testhelper" 6 | "github.com/cresta/pipe" 7 | "github.com/stretchr/testify/require" 8 | "go.uber.org/zap/zaptest" 9 | "path/filepath" 10 | "testing" 11 | ) 12 | 13 | func TestClient_Init(t *testing.T) { 14 | testhelper.ReadEnvFile(t, "../../") 15 | c := Client{ 16 | Directory: testhelper.EnvOrSkip(t, "TERRAFORM_DIR"), 17 | Logger: zaptest.NewLogger(t), 18 | } 19 | require.NoError(t, c.Init(context.Background(), testhelper.EnvOrSkip(t, "TERRAFORM_SUBDIR"))) 20 | } 21 | 22 | func TestClient_InitEmptydir(t *testing.T) { 23 | td := t.TempDir() 24 | c := Client{ 25 | Directory: td, 26 | Logger: zaptest.NewLogger(t), 27 | } 28 | require.NoError(t, c.Init(context.Background(), "")) 29 | } 30 | 31 | func TestClient_ListWorkspaces(t *testing.T) { 32 | testhelper.ReadEnvFile(t, "../../") 33 | td := t.TempDir() 34 | c := Client{ 35 | Directory: td, 36 | Logger: zaptest.NewLogger(t), 37 | } 38 | const subdir = "" 39 | require.NoError(t, c.Init(context.Background(), subdir)) 40 | workspaces, err := c.ListWorkspaces(context.Background(), subdir) 41 | require.NoError(t, err) 42 | require.Equal(t, []string{"default"}, workspaces) 43 | ctx := context.Background() 44 | require.NoError(t, pipe.NewPiped("terraform", "workspace", "new", "testing").WithDir(filepath.Join(c.Directory)).Run(ctx)) 45 | workspaces, err = c.ListWorkspaces(context.Background(), subdir) 46 | require.NoError(t, err) 47 | require.Equal(t, []string{"default", "testing"}, workspaces) 48 | } 49 | -------------------------------------------------------------------------------- /internal/notification/workflow.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | 7 | "github.com/cresta/gogithub" 8 | ) 9 | 10 | func NewWorkflow(ghClient gogithub.GitHub, owner string, repo string, id string, ref string) *Workflow { 11 | if owner == "" || repo == "" || id == "" || ref == "" { 12 | return nil 13 | } 14 | return &Workflow{ 15 | WorkflowOwner: owner, 16 | WorkflowRepo: repo, 17 | WorkflowId: id, 18 | WorkflowRef: ref, 19 | GhClient: ghClient, 20 | } 21 | } 22 | 23 | type Workflow struct { 24 | GhClient gogithub.GitHub 25 | WorkflowOwner string 26 | WorkflowRepo string 27 | WorkflowId string 28 | WorkflowRef string 29 | 30 | mu sync.Mutex 31 | directoriesDone map[string]struct{} 32 | } 33 | 34 | func (w *Workflow) TemporaryError(_ context.Context, _ string, _ string, _ error) error { 35 | // Ignored 36 | return nil 37 | } 38 | 39 | func (w *Workflow) ExtraWorkspaceInRemote(_ context.Context, _ string, _ string) error { 40 | return nil 41 | } 42 | 43 | func (w *Workflow) MissingWorkspaceInRemote(_ context.Context, _ string, _ string) error { 44 | return nil 45 | } 46 | 47 | func (w *Workflow) PlanDrift(ctx context.Context, dir string, _ string) error { 48 | w.mu.Lock() 49 | defer w.mu.Unlock() 50 | if w.directoriesDone == nil { 51 | w.directoriesDone = make(map[string]struct{}) 52 | } 53 | if _, ok := w.directoriesDone[dir]; ok { 54 | return nil 55 | } 56 | w.directoriesDone[dir] = struct{}{} 57 | return w.GhClient.TriggerWorkflow(ctx, w.WorkflowOwner, w.WorkflowRepo, w.WorkflowId, w.WorkflowRef, map[string]string{ 58 | "directory": dir, 59 | }) 60 | } 61 | 62 | var _ Notification = &Workflow{} 63 | -------------------------------------------------------------------------------- /internal/terraform/terraform.go: -------------------------------------------------------------------------------- 1 | package terraform 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "github.com/cresta/pipe" 8 | "go.uber.org/zap" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | type Client struct { 14 | Directory string 15 | Logger *zap.Logger 16 | } 17 | 18 | type execErr struct { 19 | stdout bytes.Buffer 20 | stderr bytes.Buffer 21 | root error 22 | } 23 | 24 | func (e *execErr) Unwrap() error { 25 | return e.root 26 | } 27 | 28 | func (e *execErr) Error() string { 29 | return fmt.Sprintf("%s:%s:%s", e.stdout.String(), e.stderr.String(), e.root.Error()) 30 | } 31 | 32 | func (c *Client) Init(ctx context.Context, subDir string) error { 33 | c.Logger.Info("Initializing terraform", zap.String("dir", subDir)) 34 | var stdout, stderr bytes.Buffer 35 | result := pipe.NewPiped("terraform", "init", "-no-color").WithDir(filepath.Join(c.Directory, subDir)).Execute(ctx, nil, &stdout, &stderr) 36 | if result != nil { 37 | return &execErr{ 38 | stdout: stdout, 39 | stderr: stderr, 40 | root: result, 41 | } 42 | } 43 | return nil 44 | } 45 | 46 | func (c *Client) ListWorkspaces(ctx context.Context, subDir string) ([]string, error) { 47 | c.Logger.Info("Listing workspaces", zap.String("dir", subDir)) 48 | var stdout, stderr bytes.Buffer 49 | result := pipe.NewPiped("terraform", "workspace", "list").WithDir(filepath.Join(c.Directory, subDir)).Execute(ctx, nil, &stdout, &stderr) 50 | if result != nil { 51 | return nil, &execErr{ 52 | stdout: stdout, 53 | stderr: stderr, 54 | root: result, 55 | } 56 | } 57 | lines := strings.Split(stdout.String(), "\n") 58 | workspaces := make([]string, 0, len(lines)) 59 | for _, line := range lines { 60 | line = strings.TrimPrefix(line, "* ") 61 | line = strings.TrimSpace(line) 62 | if line == "" { 63 | continue 64 | } 65 | workspaces = append(workspaces, line) 66 | } 67 | return workspaces, nil 68 | } 69 | -------------------------------------------------------------------------------- /internal/atlantis/config_test.go: -------------------------------------------------------------------------------- 1 | package atlantis 2 | 3 | import ( 4 | "github.com/stretchr/testify/require" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | const exampleAtlantis = `version: 3 11 | projects: 12 | - dir: environments/aws/example 13 | autoplan: 14 | when_modified: 15 | - '*.tf' 16 | - dir: environments/aws/account/datadog 17 | workspace: dev 18 | autoplan: 19 | when_modified: 20 | - '*.tf' 21 | - dir: environments/aws/account/datadog 22 | workspace: prod 23 | autoplan: 24 | when_modified: 25 | - '*.tf' 26 | ` 27 | 28 | const exampleFromGithubIssue = `version: 3 29 | automerge: true 30 | delete_source_branch_on_merge: true 31 | parallel_plan: true 32 | parallel_apply: true 33 | allowed_regexp_prefixes: 34 | - lab/ 35 | - staging/ 36 | - prod/ 37 | projects: 38 | - name: pepe-ue2-lab-cloudtrail 39 | workspace: pepe-ue2-lab 40 | workflow: workflow-1 41 | dir: components/terraform/cloudtrail 42 | terraform_version: v1.2.9 43 | delete_source_branch_on_merge: false 44 | autoplan: 45 | enabled: true 46 | when_modified: 47 | - '**/*.tf' 48 | - $PROJECT_NAME.tfvars.json 49 | apply_requirements: 50 | - approved` 51 | 52 | func TestParseRepoConfig(t *testing.T) { 53 | _, err := ParseRepoConfig(exampleFromGithubIssue) 54 | require.NoError(t, err) 55 | } 56 | 57 | func TestParseRepoConfigFromDir(t *testing.T) { 58 | dirName, err := os.MkdirTemp("", "config-test") 59 | require.NoError(t, err) 60 | defer func(path string) { 61 | err := os.RemoveAll(path) 62 | require.NoError(t, err) 63 | }(dirName) 64 | fp := filepath.Join(dirName, "atlantis.yaml") 65 | require.NoError(t, os.WriteFile(fp, []byte(exampleAtlantis), 0644)) 66 | cfg, err := ParseRepoConfigFromDir(dirName) 67 | require.NoError(t, err) 68 | require.Equal(t, 3, cfg.Version) 69 | require.Equal(t, 3, len(cfg.Projects)) 70 | require.Equal(t, "environments/aws/example", cfg.Projects[0].Dir) 71 | } 72 | -------------------------------------------------------------------------------- /internal/atlantis/client_test.go: -------------------------------------------------------------------------------- 1 | package atlantis 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/cresta/atlantis-drift-detection/internal/testhelper" 7 | "github.com/stretchr/testify/require" 8 | "net/http" 9 | "testing" 10 | ) 11 | 12 | func makeTestClient(t *testing.T) *Client { 13 | c := Client{ 14 | AtlantisHostname: testhelper.EnvOrSkip(t, "ATLANTIS_HOST"), 15 | Token: testhelper.EnvOrSkip(t, "ATLANTIS_TOKEN"), 16 | HTTPClient: http.DefaultClient, 17 | } 18 | return &c 19 | } 20 | 21 | func loadPlanSummaryOk(t *testing.T) *PlanSummaryRequest { 22 | return loadPlanSummaryRequest(t, "PLAN_SUMMARY_OK") 23 | } 24 | 25 | func loadPlanSummaryRequest(t *testing.T, name string) *PlanSummaryRequest { 26 | body := testhelper.EnvOrSkip(t, name) 27 | var ret PlanSummaryRequest 28 | require.NoError(t, json.Unmarshal([]byte(body), &ret)) 29 | return &ret 30 | } 31 | 32 | func loadPlanSummaryLock(t *testing.T) *PlanSummaryRequest { 33 | return loadPlanSummaryRequest(t, "PLAN_SUMMARY_LOCK") 34 | } 35 | 36 | func loadPlanSummaryChanges(t *testing.T) *PlanSummaryRequest { 37 | return loadPlanSummaryRequest(t, "PLAN_SUMMARY_CHANGES") 38 | } 39 | 40 | func TestClient_PlanSummaryLock(t *testing.T) { 41 | testhelper.ReadEnvFile(t, "../../") 42 | c := makeTestClient(t) 43 | req := loadPlanSummaryLock(t) 44 | ok, err := c.PlanSummary(context.Background(), req) 45 | require.NoError(t, err) 46 | require.True(t, ok.IsLocked()) 47 | } 48 | 49 | func TestClient_PlanSummaryOk(t *testing.T) { 50 | testhelper.ReadEnvFile(t, "../../") 51 | c := makeTestClient(t) 52 | req := loadPlanSummaryOk(t) 53 | ok, err := c.PlanSummary(context.Background(), req) 54 | require.NoError(t, err) 55 | require.False(t, ok.HasChanges()) 56 | } 57 | 58 | func TestClient_PlanSummaryChanges(t *testing.T) { 59 | testhelper.ReadEnvFile(t, "../../") 60 | c := makeTestClient(t) 61 | req := loadPlanSummaryChanges(t) 62 | ok, err := c.PlanSummary(context.Background(), req) 63 | require.NoError(t, err) 64 | require.True(t, ok.HasChanges()) 65 | } 66 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and test code 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | branches: 8 | - master 9 | - main 10 | pull_request: 11 | 12 | 13 | jobs: 14 | build: 15 | name: Test 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: read 19 | packages: write 20 | steps: 21 | - name: Check out code 22 | uses: actions/checkout@v4 23 | - name: Install Go 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version-file: 'go.mod' 27 | - name: golangci-lint 28 | uses: golangci/golangci-lint-action@v6 29 | with: 30 | version: latest 31 | args: "--timeout 5m" 32 | - name: Build 33 | run: go build -mod=readonly ./cmd/atlantis-drift-detection/main.go 34 | - name: Verify 35 | run: go mod verify 36 | - name: Setup Terraform 37 | uses: hashicorp/setup-terraform@v3 38 | - name: Test 39 | run: go test -v ./... 40 | - name: Set up Docker Buildx 41 | uses: docker/setup-buildx-action@v3 42 | id: buildx 43 | with: 44 | install: true 45 | - name: Login to GitHub Container Registry 46 | uses: docker/login-action@v3 47 | with: 48 | registry: ghcr.io 49 | username: ${{ github.actor }} 50 | password: ${{ secrets.GITHUB_TOKEN }} 51 | - name: Docker metadata 52 | id: meta 53 | uses: docker/metadata-action@v5 54 | with: 55 | tags: | 56 | type=ref,event=branch 57 | type=ref,event=tag 58 | type=ref,event=pr 59 | type=semver,pattern=v{{major}},enable=${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 60 | images: | 61 | ghcr.io/cresta/atlantis-drift-detection 62 | - name: Build and push 63 | uses: docker/build-push-action@v6 64 | with: 65 | # Push only if tag 66 | push: ${{ github.event_name == 'push' && contains(github.ref, 'refs/tags/') }} 67 | platforms: linux/amd64 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} -------------------------------------------------------------------------------- /internal/notification/slackwebhook.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | ) 10 | 11 | type SlackWebhook struct { 12 | WebhookURL string 13 | HTTPClient *http.Client 14 | } 15 | 16 | func (s *SlackWebhook) TemporaryError(ctx context.Context, dir string, workspace string, err error) error { 17 | return s.sendSlackMessage(ctx, fmt.Sprintf("Unknown error in remote\nDirectory: %s\nWorkspace: %s\nError: %s", dir, workspace, err.Error())) 18 | } 19 | 20 | func NewSlackWebhook(webhookURL string, HTTPClient *http.Client) *SlackWebhook { 21 | if webhookURL == "" { 22 | return nil 23 | } 24 | return &SlackWebhook{ 25 | WebhookURL: webhookURL, 26 | HTTPClient: HTTPClient, 27 | } 28 | } 29 | 30 | type SlackWebhookMessage struct { 31 | Text string `json:"text"` 32 | } 33 | 34 | func (s *SlackWebhook) sendSlackMessage(ctx context.Context, msg string) error { 35 | body := SlackWebhookMessage{ 36 | Text: msg, 37 | } 38 | b, err := json.Marshal(body) 39 | if err != nil { 40 | return fmt.Errorf("failed to marshal slack webhook message: %w", err) 41 | } 42 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.WebhookURL, bytes.NewReader(b)) 43 | if err != nil { 44 | return fmt.Errorf("failed to create slack webhook request: %w", err) 45 | } 46 | req.Header.Set("Content-Type", "application/json") 47 | resp, err := s.HTTPClient.Do(req) 48 | if err != nil { 49 | return fmt.Errorf("failed to send slack webhook request: %w", err) 50 | } 51 | if resp.StatusCode != http.StatusOK { 52 | return fmt.Errorf("failed to send slack webhook request: %w", err) 53 | } 54 | return nil 55 | } 56 | 57 | func (s *SlackWebhook) ExtraWorkspaceInRemote(ctx context.Context, dir string, workspace string) error { 58 | return s.sendSlackMessage(ctx, fmt.Sprintf("Extra workspace in remote\nDirectory: %s\nWorkspace: %s", dir, workspace)) 59 | } 60 | 61 | func (s *SlackWebhook) MissingWorkspaceInRemote(ctx context.Context, dir string, workspace string) error { 62 | return s.sendSlackMessage(ctx, fmt.Sprintf("Missing workspace in remote\nDirectory: %s\nWorkspace: %s", dir, workspace)) 63 | } 64 | 65 | func (s *SlackWebhook) PlanDrift(ctx context.Context, dir string, workspace string) error { 66 | return s.sendSlackMessage(ctx, fmt.Sprintf("Plan Drift workspace in remote\nDirectory: %s\nWorkspace: %s", dir, workspace)) 67 | } 68 | 69 | var _ Notification = &SlackWebhook{} 70 | -------------------------------------------------------------------------------- /internal/processedcache/cache.go: -------------------------------------------------------------------------------- 1 | package processedcache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | type ConsiderDriftChecked struct { 10 | // The directory checked 11 | Dir string 12 | // The workspace checked 13 | Workspace string 14 | } 15 | 16 | func (d *ConsiderDriftChecked) String() string { 17 | return fmt.Sprintf("%s:%s", d.Dir, d.Workspace) 18 | } 19 | 20 | type DriftCheckValue struct { 21 | // If non-empty, indicates an error in the checking 22 | Error string 23 | // Only if we have an empty error: the result of checking for drift 24 | Drift bool `json:"drift"` 25 | // Only if we have an empty error: when we did this check 26 | When time.Time 27 | } 28 | 29 | type ConsiderWorkspacesChecked struct { 30 | // Directory checked 31 | Dir string 32 | } 33 | 34 | func (d *ConsiderWorkspacesChecked) String() string { 35 | return d.Dir 36 | } 37 | 38 | type WorkspacesCheckedValue struct { 39 | // If non-empty, indicates an error in the checking 40 | Error string 41 | // Worksaces we remember in this remote 42 | Workspaces []string 43 | // Only if we have an empty error: when we did this check 44 | When time.Time 45 | } 46 | 47 | type ProcessedCache interface { 48 | GetDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) (*DriftCheckValue, error) 49 | DeleteDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) error 50 | StoreDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked, value *DriftCheckValue) error 51 | GetRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) (*WorkspacesCheckedValue, error) 52 | StoreRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked, value *WorkspacesCheckedValue) error 53 | DeleteRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) error 54 | } 55 | 56 | type Noop struct{} 57 | 58 | func (n Noop) GetDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) (*DriftCheckValue, error) { 59 | return nil, nil 60 | } 61 | 62 | func (n Noop) DeleteDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) error { 63 | return nil 64 | } 65 | 66 | func (n Noop) StoreDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked, value *DriftCheckValue) error { 67 | return nil 68 | } 69 | 70 | func (n Noop) GetRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) (*WorkspacesCheckedValue, error) { 71 | return nil, nil 72 | } 73 | 74 | func (n Noop) StoreRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked, value *WorkspacesCheckedValue) error { 75 | return nil 76 | } 77 | 78 | func (n Noop) DeleteRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) error { 79 | return nil 80 | } 81 | 82 | var _ ProcessedCache = &Noop{} 83 | -------------------------------------------------------------------------------- /internal/atlantis/client.go: -------------------------------------------------------------------------------- 1 | package atlantis 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "strings" 11 | 12 | "github.com/runatlantis/atlantis/server/controllers" 13 | "github.com/runatlantis/atlantis/server/events/command" 14 | ) 15 | 16 | type Client struct { 17 | AtlantisHostname string 18 | Token string 19 | HTTPClient *http.Client 20 | } 21 | 22 | type PlanSummaryRequest struct { 23 | Repo string 24 | Ref string 25 | Type string 26 | Dir string 27 | Workspace string 28 | } 29 | 30 | type PlanResult struct { 31 | Summaries []PlanSummary 32 | } 33 | 34 | type PlanSummary struct { 35 | HasLock bool 36 | Summary string 37 | } 38 | 39 | func (p *PlanResult) HasChanges() bool { 40 | for _, summary := range p.Summaries { 41 | if summary.HasLock { 42 | continue 43 | } 44 | if !strings.Contains(summary.Summary, "No changes. ") { 45 | return true 46 | } 47 | } 48 | return false 49 | } 50 | 51 | func (p *PlanResult) IsLocked() bool { 52 | for _, summary := range p.Summaries { 53 | if !summary.HasLock { 54 | return false 55 | } 56 | } 57 | return true 58 | } 59 | 60 | type possiblyTemporaryError struct { 61 | error 62 | } 63 | 64 | type TemporaryError interface { 65 | Temporary() bool 66 | error 67 | } 68 | 69 | type errorResponse struct { 70 | Error string `json:"error"` 71 | } 72 | 73 | func (p *possiblyTemporaryError) Temporary() bool { 74 | return true 75 | } 76 | 77 | func (c *Client) PlanSummary(ctx context.Context, req *PlanSummaryRequest) (*PlanResult, error) { 78 | planBody := controllers.APIRequest{ 79 | Repository: req.Repo, 80 | Ref: req.Ref, 81 | Type: req.Type, 82 | Paths: []struct { 83 | Directory string 84 | Workspace string 85 | }{ 86 | { 87 | Directory: req.Dir, 88 | Workspace: req.Workspace, 89 | }, 90 | }, 91 | } 92 | planBodyJSON, err := json.Marshal(planBody) 93 | if err != nil { 94 | return nil, fmt.Errorf("error marshalling plan body: %w", err) 95 | } 96 | destination := fmt.Sprintf("%s/api/plan", c.AtlantisHostname) 97 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, destination, strings.NewReader(string(planBodyJSON))) 98 | if err != nil { 99 | return nil, fmt.Errorf("error parsing destination: %w", err) 100 | } 101 | httpReq.Header.Set("X-Atlantis-Token", c.Token) 102 | httpReq = httpReq.WithContext(ctx) 103 | 104 | resp, err := c.HTTPClient.Do(httpReq) 105 | if err != nil { 106 | return nil, fmt.Errorf("error making plan request to %s: %w", destination, err) 107 | } 108 | var fullBody bytes.Buffer 109 | if _, err := io.Copy(&fullBody, resp.Body); err != nil { 110 | return nil, fmt.Errorf("unable to read response body: %w", err) 111 | } 112 | if err := resp.Body.Close(); err != nil { 113 | return nil, fmt.Errorf("unable to close response body: %w", err) 114 | } 115 | if resp.StatusCode == http.StatusUnauthorized { 116 | var errResp errorResponse 117 | if err := json.NewDecoder(&fullBody).Decode(&errResp); err != nil { 118 | return nil, fmt.Errorf("unauthorized request to %s: %w", destination, err) 119 | } 120 | return nil, fmt.Errorf("unauthorized request to %s: %s", destination, errResp.Error) 121 | } 122 | 123 | var bodyResult command.Result 124 | if err := json.NewDecoder(&fullBody).Decode(&bodyResult); err != nil { 125 | retErr := fmt.Errorf("error decoding plan response(code:%d)(status:%s)(body:%s): %w", resp.StatusCode, resp.Status, fullBody.String(), err) 126 | if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusInternalServerError { 127 | // This is a bit of a hack, but atlantis sometimes returns errors we can't fully process. These could be 128 | // because the workspace won't apply, or because the service is just overloaded. We cannot tell. 129 | return nil, &possiblyTemporaryError{retErr} 130 | } 131 | return nil, retErr 132 | } 133 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusInternalServerError { 134 | return nil, fmt.Errorf("non-200 and non-500 response for %s: %d", destination, resp.StatusCode) 135 | } 136 | 137 | if bodyResult.Error != nil { 138 | return nil, fmt.Errorf("error making plan request: %w", bodyResult.Error) 139 | } 140 | if bodyResult.Failure != "" { 141 | return nil, fmt.Errorf("failure making plan request: %s", bodyResult.Failure) 142 | } 143 | var ret PlanResult 144 | for _, result := range bodyResult.ProjectResults { 145 | if result.Failure != "" { 146 | if strings.Contains(result.Failure, "This project is currently locked ") { 147 | ret.Summaries = append(ret.Summaries, PlanSummary{HasLock: true}) 148 | continue 149 | } 150 | } 151 | if result.PlanSuccess != nil { 152 | summary := result.PlanSuccess.Summary() 153 | ret.Summaries = append(ret.Summaries, PlanSummary{Summary: summary}) 154 | continue 155 | } 156 | return nil, fmt.Errorf("project result unknown failure: %s", result.Failure) 157 | 158 | } 159 | return &ret, nil 160 | } 161 | -------------------------------------------------------------------------------- /internal/processedcache/dynamodb.go: -------------------------------------------------------------------------------- 1 | package processedcache 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "github.com/aws/aws-sdk-go-v2/config" 7 | "github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue" 8 | "github.com/aws/aws-sdk-go-v2/service/dynamodb" 9 | "github.com/aws/aws-sdk-go-v2/service/dynamodb/types" 10 | ) 11 | 12 | type DynamoDB struct { 13 | Client *dynamodb.Client 14 | Table string 15 | } 16 | 17 | func NewDynamoDB(ctx context.Context, table string) (*DynamoDB, error) { 18 | cfg, err := config.LoadDefaultConfig(context.Background()) 19 | if err != nil { 20 | return nil, fmt.Errorf("failed to load config: %w", err) 21 | } 22 | c := DynamoDB{ 23 | Client: dynamodb.NewFromConfig(cfg), 24 | Table: table, 25 | } 26 | _, err = c.GetRemoteWorkspaces(ctx, &ConsiderWorkspacesChecked{Dir: "test"}) 27 | if err != nil { 28 | return nil, fmt.Errorf("failed to verify dynamodb cache: %w", err) 29 | } 30 | return &c, nil 31 | } 32 | 33 | func dynamoKeyForDriftCheckResultKey(keyType string, k fmt.Stringer) map[string]types.AttributeValue { 34 | return map[string]types.AttributeValue{ 35 | "K": &types.AttributeValueMemberS{Value: fmt.Sprintf("%s:%s", keyType, k.String())}, 36 | } 37 | } 38 | 39 | func dynamoKeyForDriftCheckResultValue(keyType string, key fmt.Stringer, value any) (map[string]types.AttributeValue, error) { 40 | var allItems []map[string]types.AttributeValue 41 | allItems = append(allItems, dynamoKeyForDriftCheckResultKey(keyType, key)) 42 | if i, err := attributevalue.MarshalMap(key); err != nil { 43 | return nil, fmt.Errorf("failed to marshal key: %w", err) 44 | } else { 45 | allItems = append(allItems, i) 46 | } 47 | if i, err := attributevalue.MarshalMap(value); err != nil { 48 | return nil, fmt.Errorf("failed to marshal value: %w", err) 49 | } else { 50 | allItems = append(allItems, i) 51 | } 52 | ret := make(map[string]types.AttributeValue) 53 | for _, item := range allItems { 54 | for k, v := range item { 55 | ret[k] = v 56 | } 57 | } 58 | return ret, nil 59 | } 60 | 61 | func (d *DynamoDB) genericGet(ctx context.Context, keyType string, key fmt.Stringer, into any) (bool, error) { 62 | input := &dynamodb.GetItemInput{ 63 | TableName: &d.Table, 64 | Key: dynamoKeyForDriftCheckResultKey(keyType, key), 65 | } 66 | output, err := d.Client.GetItem(ctx, input) 67 | if err != nil { 68 | return false, fmt.Errorf("failed to get drift check result: %w", err) 69 | } 70 | if output.Item == nil { 71 | return false, nil 72 | } 73 | if err := attributevalue.UnmarshalMap(output.Item, into); err != nil { 74 | return false, fmt.Errorf("failed to unmarshal drift check result: %w", err) 75 | } 76 | return true, nil 77 | } 78 | 79 | func (d *DynamoDB) genericDelete(ctx context.Context, keyType string, key fmt.Stringer) error { 80 | input := &dynamodb.DeleteItemInput{ 81 | TableName: &d.Table, 82 | Key: dynamoKeyForDriftCheckResultKey(keyType, key), 83 | } 84 | _, err := d.Client.DeleteItem(ctx, input) 85 | if err != nil { 86 | return fmt.Errorf("failed to delete drift check result: %w", err) 87 | } 88 | return nil 89 | } 90 | 91 | func (d *DynamoDB) genericStore(ctx context.Context, keyType string, key fmt.Stringer, value any) error { 92 | item, err := dynamoKeyForDriftCheckResultValue(keyType, key, value) 93 | if err != nil { 94 | return fmt.Errorf("failed to marshal drift check result: %w", err) 95 | } 96 | input := &dynamodb.PutItemInput{ 97 | TableName: &d.Table, 98 | Item: item, 99 | } 100 | _, err = d.Client.PutItem(ctx, input) 101 | if err != nil { 102 | return fmt.Errorf("failed to store drift check result: %w", err) 103 | } 104 | return nil 105 | } 106 | 107 | func (d *DynamoDB) GetDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) (*DriftCheckValue, error) { 108 | var ret DriftCheckValue 109 | if exists, err := d.genericGet(ctx, "ConsiderDriftChecked", key, &ret); err != nil { 110 | return nil, err 111 | } else if !exists { 112 | return nil, nil 113 | } 114 | return &ret, nil 115 | } 116 | 117 | func (d *DynamoDB) DeleteDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked) error { 118 | return d.genericDelete(ctx, "ConsiderDriftChecked", key) 119 | } 120 | 121 | func (d *DynamoDB) StoreDriftCheckResult(ctx context.Context, key *ConsiderDriftChecked, value *DriftCheckValue) error { 122 | return d.genericStore(ctx, "ConsiderDriftChecked", key, value) 123 | } 124 | 125 | func (d *DynamoDB) GetRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) (*WorkspacesCheckedValue, error) { 126 | var ret WorkspacesCheckedValue 127 | if exists, err := d.genericGet(ctx, "ConsiderWorkspacesChecked", key, &ret); err != nil { 128 | return nil, err 129 | } else if !exists { 130 | return nil, nil 131 | } 132 | return &ret, nil 133 | } 134 | 135 | func (d *DynamoDB) StoreRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked, value *WorkspacesCheckedValue) error { 136 | return d.genericStore(ctx, "ConsiderWorkspacesChecked", key, value) 137 | } 138 | 139 | func (d *DynamoDB) DeleteRemoteWorkspaces(ctx context.Context, key *ConsiderWorkspacesChecked) error { 140 | return d.genericDelete(ctx, "ConsiderWorkspacesChecked", key) 141 | } 142 | 143 | var _ ProcessedCache = &DynamoDB{} 144 | -------------------------------------------------------------------------------- /cmd/atlantis-drift-detection/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "os" 8 | "time" 9 | 10 | "github.com/cresta/atlantis-drift-detection/internal/atlantis" 11 | "github.com/cresta/atlantis-drift-detection/internal/drifter" 12 | "github.com/cresta/atlantis-drift-detection/internal/notification" 13 | "github.com/cresta/atlantis-drift-detection/internal/processedcache" 14 | "github.com/cresta/atlantis-drift-detection/internal/terraform" 15 | "github.com/cresta/gogit" 16 | "github.com/cresta/gogithub" 17 | "github.com/joho/godotenv" 18 | 19 | // Empty import allows pinning to version atlantis uses 20 | _ "github.com/nlopes/slack" 21 | "go.uber.org/zap" 22 | ) 23 | import "github.com/joeshaw/envdecode" 24 | 25 | type config struct { 26 | Repo string `env:"REPO,required"` 27 | AtlantisHostname string `env:"ATLANTIS_HOST,required"` 28 | AtlantisToken string `env:"ATLANTIS_TOKEN,required"` 29 | DirectoryWhitelist []string `env:"DIRECTORY_WHITELIST"` 30 | SlackWebhookURL string `env:"SLACK_WEBHOOK_URL"` 31 | SkipWorkspaceCheck bool `env:"SKIP_WORKSPACE_CHECK"` 32 | ParallelRuns int `env:"PARALLEL_RUNS"` 33 | DynamodbTable string `env:"DYNAMODB_TABLE"` 34 | CacheValidDuration time.Duration `env:"CACHE_VALID_DURATION,default=24h"` 35 | WorkflowOwner string `env:"WORKFLOW_OWNER"` 36 | WorkflowRepo string `env:"WORKFLOW_REPO"` 37 | WorkflowId string `env:"WORKFLOW_ID"` 38 | WorkflowRef string `env:"WORKFLOW_REF"` 39 | } 40 | 41 | func loadEnvIfExists() error { 42 | _, err := os.Stat(".env") 43 | if err != nil { 44 | if os.IsNotExist(err) { 45 | return nil 46 | } 47 | return fmt.Errorf("error checking for .env file: %v", err) 48 | } 49 | return godotenv.Load() 50 | } 51 | 52 | type zapGogitLogger struct { 53 | logger *zap.Logger 54 | } 55 | 56 | func (z *zapGogitLogger) build(strings map[string]string, ints map[string]int64) *zap.Logger { 57 | l := z.logger 58 | for k, v := range strings { 59 | l = l.With(zap.String(k, v)) 60 | } 61 | for k, v := range ints { 62 | l = l.With(zap.Int64(k, v)) 63 | } 64 | return l 65 | } 66 | 67 | func (z *zapGogitLogger) Debug(_ context.Context, msg string, strings map[string]string, ints map[string]int64) { 68 | z.build(strings, ints).Debug(msg) 69 | } 70 | 71 | func (z *zapGogitLogger) Info(_ context.Context, msg string, strings map[string]string, ints map[string]int64) { 72 | z.build(strings, ints).Info(msg) 73 | } 74 | 75 | var _ gogit.Logger = (*zapGogitLogger)(nil) 76 | 77 | func main() { 78 | ctx := context.Background() 79 | zapCfg := zap.NewProductionConfig() 80 | zapCfg.Level = zap.NewAtomicLevelAt(zap.DebugLevel) 81 | logger, err := zapCfg.Build(zap.AddCaller()) 82 | if err != nil { 83 | panic(err) 84 | } 85 | if err := loadEnvIfExists(); err != nil { 86 | logger.Panic("Failed to load .env", zap.Error(err)) 87 | } 88 | var cfg config 89 | if err := envdecode.Decode(&cfg); err != nil { 90 | logger.Panic("failed to decode config", zap.Error(err)) 91 | } 92 | cloner := &gogit.Cloner{ 93 | Logger: &zapGogitLogger{logger}, 94 | } 95 | notif := ¬ification.Multi{ 96 | Notifications: []notification.Notification{ 97 | ¬ification.Zap{Logger: logger.With(zap.String("notification", "true"))}, 98 | }, 99 | } 100 | if slackClient := notification.NewSlackWebhook(cfg.SlackWebhookURL, http.DefaultClient); slackClient != nil { 101 | logger.Info("setting up slack webhook notification") 102 | notif.Notifications = append(notif.Notifications, slackClient) 103 | } 104 | var existingConfig *gogithub.NewGQLClientConfig 105 | if os.Getenv("GITHUB_TOKEN") != "" { 106 | existingConfig = &gogithub.NewGQLClientConfig{Token: os.Getenv("GITHUB_TOKEN")} 107 | } 108 | ghClient, err := gogithub.NewGQLClient(ctx, logger, existingConfig) 109 | if err != nil { 110 | logger.Panic("failed to create github client", zap.Error(err)) 111 | } 112 | if workflowClient := notification.NewWorkflow(ghClient, cfg.WorkflowOwner, cfg.WorkflowRepo, cfg.WorkflowId, cfg.WorkflowRef); workflowClient != nil { 113 | logger.Info("setting up workflow notification") 114 | notif.Notifications = append(notif.Notifications, workflowClient) 115 | } 116 | tf := terraform.Client{ 117 | Logger: logger.With(zap.String("terraform", "true")), 118 | } 119 | 120 | var cache processedcache.ProcessedCache = processedcache.Noop{} 121 | if cfg.DynamodbTable != "" { 122 | logger.Info("setting up dynamodb result cache") 123 | cache, err = processedcache.NewDynamoDB(ctx, cfg.DynamodbTable) 124 | if err != nil { 125 | logger.Panic("failed to create dynamodb result cache", zap.Error(err)) 126 | } 127 | } 128 | 129 | d := drifter.Drifter{ 130 | DirectoryWhitelist: cfg.DirectoryWhitelist, 131 | Logger: logger.With(zap.String("drifter", "true")), 132 | Repo: cfg.Repo, 133 | AtlantisClient: &atlantis.Client{ 134 | AtlantisHostname: cfg.AtlantisHostname, 135 | Token: cfg.AtlantisToken, 136 | HTTPClient: http.DefaultClient, 137 | }, 138 | ParallelRuns: cfg.ParallelRuns, 139 | ResultCache: cache, 140 | Cloner: cloner, 141 | GithubClient: ghClient, 142 | CacheValidDuration: cfg.CacheValidDuration, 143 | Terraform: &tf, 144 | Notification: notif, 145 | SkipWorkspaceCheck: cfg.SkipWorkspaceCheck, 146 | } 147 | if err := d.Drift(ctx); err != nil { 148 | logger.Panic("failed to drift", zap.Error(err)) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/cresta/atlantis-drift-detection 2 | 3 | go 1.24.4 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go-v2/config v1.32.6 7 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29 8 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5 9 | github.com/cresta/gogit v0.0.2 10 | github.com/cresta/gogithub v0.2.0 11 | github.com/cresta/pipe v0.0.1 12 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd 13 | github.com/joho/godotenv v1.5.1 14 | github.com/nlopes/slack v0.6.0 15 | github.com/runatlantis/atlantis v0.36.0 16 | github.com/stretchr/testify v1.11.1 17 | go.uber.org/zap v1.27.1 18 | golang.org/x/sync v0.19.0 19 | gopkg.in/yaml.v3 v3.0.1 20 | ) 21 | 22 | require ( 23 | code.gitea.io/sdk/gitea v0.21.0 // indirect 24 | dario.cat/mergo v1.0.1 // indirect 25 | github.com/42wim/httpsig v1.2.2 // indirect 26 | github.com/Masterminds/goutils v1.1.1 // indirect 27 | github.com/Masterminds/semver/v3 v3.4.0 // indirect 28 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 29 | github.com/ProtonMail/go-crypto v1.1.6 // indirect 30 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect 31 | github.com/ProtonMail/gopenpgp/v2 v2.7.5 // indirect 32 | github.com/agext/levenshtein v1.2.3 // indirect 33 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 34 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 35 | github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect 36 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 // indirect 37 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect 38 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect 39 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect 40 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect 41 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect 46 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect 47 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect 48 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect 49 | github.com/aws/smithy-go v1.24.0 // indirect 50 | github.com/beorn7/perks v1.0.1 // indirect 51 | github.com/bmatcuk/doublestar/v4 v4.8.1 // indirect 52 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 // indirect 53 | github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect 54 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 55 | github.com/cloudflare/circl v1.6.1 // indirect 56 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 57 | github.com/davidmz/go-pageant v1.0.2 // indirect 58 | github.com/drmaxgit/go-azuredevops v0.13.2 // indirect 59 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 60 | github.com/go-fed/httpsig v1.1.0 // indirect 61 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible // indirect 62 | github.com/go-playground/locales v0.14.1 // indirect 63 | github.com/go-playground/universal-translator v0.18.1 // indirect 64 | github.com/go-playground/validator/v10 v10.26.0 // indirect 65 | github.com/gofri/go-github-ratelimit v1.1.1 // indirect 66 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect 67 | github.com/golang/mock v1.6.0 // indirect 68 | github.com/google/go-cmp v0.7.0 // indirect 69 | github.com/google/go-github/v71 v71.0.0 // indirect 70 | github.com/google/go-querystring v1.1.0 // indirect 71 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 72 | github.com/google/uuid v1.6.0 // indirect 73 | github.com/gorilla/mux v1.8.1 // indirect 74 | github.com/gorilla/websocket v1.5.3 // indirect 75 | github.com/hashicorp/errwrap v1.1.0 // indirect 76 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 77 | github.com/hashicorp/go-multierror v1.1.1 // indirect 78 | github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 79 | github.com/hashicorp/go-version v1.7.0 // indirect 80 | github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 81 | github.com/hashicorp/hc-install v0.9.2 // indirect 82 | github.com/hashicorp/hcl v1.0.0 // indirect 83 | github.com/hashicorp/hcl/v2 v2.23.0 // indirect 84 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250828155816-225c06ed5fd9 // indirect 85 | github.com/huandu/xstrings v1.5.0 // indirect 86 | github.com/jpillora/backoff v1.0.0 // indirect 87 | github.com/klauspost/compress v1.17.7 // indirect 88 | github.com/leodido/go-urn v1.4.0 // indirect 89 | github.com/mitchellh/copystructure v1.2.0 // indirect 90 | github.com/mitchellh/go-homedir v1.1.0 // indirect 91 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 92 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 93 | github.com/moby/patternmatcher v0.6.0 // indirect 94 | github.com/opentofu/tofudl v0.0.1 // indirect 95 | github.com/pkg/errors v0.9.1 // indirect 96 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 97 | github.com/prometheus/client_golang v1.19.0 // indirect 98 | github.com/prometheus/client_model v0.6.0 // indirect 99 | github.com/prometheus/common v0.50.0 // indirect 100 | github.com/prometheus/procfs v0.13.0 // indirect 101 | github.com/remeh/sizedwaitgroup v1.0.0 // indirect 102 | github.com/shopspring/decimal v1.4.0 // indirect 103 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 // indirect 104 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect 105 | github.com/slack-go/slack v0.16.0 // indirect 106 | github.com/spf13/cast v1.7.1 // indirect 107 | github.com/spf13/pflag v1.0.10 // indirect 108 | github.com/twmb/murmur3 v1.1.8 // indirect 109 | github.com/uber-go/tally/v4 v4.1.17 // indirect 110 | github.com/zclconf/go-cty v1.14.4 // indirect 111 | gitlab.com/gitlab-org/api/client-go v0.118.0 // indirect 112 | go.uber.org/atomic v1.11.0 // indirect 113 | go.uber.org/multierr v1.11.0 // indirect 114 | golang.org/x/crypto v0.41.0 // indirect 115 | golang.org/x/mod v0.27.0 // indirect 116 | golang.org/x/net v0.43.0 // indirect 117 | golang.org/x/oauth2 v0.27.0 // indirect 118 | golang.org/x/sys v0.35.0 // indirect 119 | golang.org/x/text v0.28.0 // indirect 120 | golang.org/x/time v0.8.0 // indirect 121 | golang.org/x/tools v0.36.0 // indirect 122 | google.golang.org/protobuf v1.36.7 // indirect 123 | ) 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # atlantis-drift-detection 2 | Detect terraform drift in atlantis 3 | 4 | # What it does 5 | 6 | The general workflow of this repository is: 7 | 1. Check out a mono repo of terraform code 8 | 2. Find an atlantis.yaml file inside the repository 9 | 3. Use atlantis to run /plan on each project in the atlantis.yaml file 10 | 4. For each project with drift 11 | 1. Trigger a GitHub workflow that can resolve the drift 12 | 2. Comment the existence of the drift in slack 13 | 5. For each project directory in the atlantis.yaml 14 | 1. Run workspace list 15 | 2. If any workspace isn't tracked by atlantis, notify slack 16 | 17 | There is an optional flag to cache drift results inside DynamoDB, so we don't check the same directory twice in a short period of time. 18 | 19 | # Example for "Trigger a github workflow that can resolve the drift" 20 | 21 | Here is the example we use to resolve a drift. This workflow touches a "trigger.txt" file that we configure all 22 | of our projects to listen to. 23 | 24 | ```yaml 25 | name: Force a terraform PR 26 | on: 27 | workflow_dispatch: 28 | inputs: 29 | directory: 30 | description: >- 31 | Which directory to force a terraform workflow upon 32 | required: true 33 | type: string 34 | jobs: 35 | plantrigger: 36 | runs-on: [self-hosted] 37 | name: Force terraform plan PR 38 | steps: 39 | # We use an application to make PRs, rather than hard code a user token 40 | - name: Generate token 41 | id: generate_token 42 | uses: peter-murray/workflow-application-token-action@v1 43 | with: 44 | application_id: $ {{ secrets.OUR_APP_ID_FOR_CREATING_PRS }} 45 | application_private_key: ${{ secrets.OUR_PEM_ID_FOR_CREATING_PRS }} 46 | - name: Checkout 47 | uses: actions/checkout@v2 48 | - run: ./scripts/update_trigger.sh ${DIR} 49 | env: 50 | DIR: ${{ github.event.inputs.directory }} 51 | - name: Create PR to terraform repo 52 | uses: peter-evans/create-pull-request@v3 53 | id: make-pr 54 | with: 55 | token: ${{ steps.generate_token.outputs.token }} 56 | branch: replan-${{ github.event.inputs.directory }} 57 | delete-branch: true 58 | title: "Force replan of directory ${{ github.event.inputs.directory }}" 59 | labels: forced-workflow 60 | committer: Forced Replan 61 | body: "A forced replan of this directory was triggered via github actions" 62 | commit-message: "Regenerated plan for ${{ github.event.inputs.directory }}" 63 | ``` 64 | 65 | Our script update_trigger.sh 66 | ```bash 67 | #!/bin/bash 68 | set -exou pipefail 69 | # Modify the trigger.txt file of a directory which we expect to trigger an atlantis workflow 70 | if [ ! -d "$1" ]; then 71 | echo "Unable to find directory $1" 72 | exit 1 73 | fi 74 | date > "$1/trigger.txt" 75 | ``` 76 | 77 | 78 | # Use as a github action 79 | 80 | ```yaml 81 | name: Drift detection 82 | on: 83 | workflow_dispatch: 84 | jobs: 85 | drift: 86 | name: detects drift 87 | runs-on: [self-hosted] 88 | steps: 89 | - name: detect drift 90 | uses: cresta/atlantis-drift-detection@v0.0.7 91 | env: 92 | ATLANTIS_HOST: atlantis.atlantis.svc.cluster.local 93 | ATLANTIS_TOKEN: ${{ secrets.ATLANTIS_TOKEN }} 94 | REPO: cresta/terraform-monorepo 95 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 96 | SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} 97 | DYNAMODB_TABLE: atlantis-drift-detection 98 | WORKFLOW_OWNER: cresta 99 | WORKFLOW_REPO: terraform-monorepo 100 | WORKFLOW_ID: force_terraform_workflow.yaml 101 | WORKFLOW_REF: master 102 | GITHUB_APP_ID: 123456 103 | GITHUB_INSTALLATION_ID: 123456 104 | GITHUB_PEM_KEY: ${{ secrets.PR_CREATOR_PEM }} 105 | CACHE_VALID_DURATION: 168h 106 | ``` 107 | 108 | # Configuration 109 | 110 | | Environment Variable | Description | Required | Default | Example | 111 | |--------------------------|----------------------------------------------------------------------------------|----------|----------------------------|---------------------------------------------------------------------| 112 | | `REPO` | The github repo to check | Yes | | `cresta/terraform-monorepo` | 113 | | `ATLANTIS_HOST` | The Hostname of the Atlantis server | Yes | | `atlantis.example.com` | 114 | | `ATLANTIS_TOKEN` | The Atlantis API token | Yes | | `1234567890` | 115 | | `WORKFLOW_OWNER` | The github owner of the workflow to trigger on drift | No | | `cresta` | 116 | | `WORKFLOW_REPO` | The github repo of the workflow to trigger on drift | No | | `atlantis-drift-detection` | 117 | | `WORKFLOW_ID` | The ID of the workflow to trigger on drift | No | | `drift.yaml` | 118 | | `WORKFLOW_REF` | The git ref to trigger the workflow on | No | | `master` | 119 | | `DIRECTORY_WHITELIST` | A comma separated list of directories to check | No | | `terraform,modules` | 120 | | `SLACK_WEBHOOK_URL` | The Slack webhook URL to post updates to | No | | `https://hooks.slack.com/services/1234567890/1234567890/1234567890` | 121 | | `SKIP_WORKSPACE_CHECK` | Skip checking if the workspace have drifted | No | `false` | `true` | 122 | | `PARALLEL_RUNS` | The number of parallel runs to use | No | `1` | `10` | 123 | | `DYNAMODB_TABLE` | The name of the DynamoDB table to use for caching results | No | `atlantis-drift-detection` | `atlantis-drift-detection` | 124 | | `CACHE_VALID_DURATION` | The duration that previous results are still valid | No | `24h` | `180h` | 125 | | `GITHUB_APP_ID` | An application ID to use for github API calls | No | | `123123` | 126 | | `GITHUB_INSTALLATION_ID` | An application install ID to use for github API calls | No | | `123123` | 127 | | `GITHUB_PEM_KEY` | A GitHub PEM key of an application, used to authenticate the app for API calls | No | | `1231DEADBEAF....` | 128 | 129 | # Local development 130 | 131 | Create a file named `.env` inside the root directory and populate it with the correct variables. 132 | Check out the [example file](example.env) or [configuration](#configuration) for details. 133 | This file won't be checked in because it's inside the [.gitignore](.gitignore). 134 | -------------------------------------------------------------------------------- /internal/drifter/drifter.go: -------------------------------------------------------------------------------- 1 | package drifter 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "github.com/cresta/atlantis-drift-detection/internal/atlantis" 8 | "github.com/cresta/atlantis-drift-detection/internal/atlantisgithub" 9 | "github.com/cresta/atlantis-drift-detection/internal/notification" 10 | "github.com/cresta/atlantis-drift-detection/internal/processedcache" 11 | "github.com/cresta/atlantis-drift-detection/internal/terraform" 12 | "github.com/cresta/gogit" 13 | "github.com/cresta/gogithub" 14 | "go.uber.org/zap" 15 | "golang.org/x/sync/errgroup" 16 | "os" 17 | "time" 18 | ) 19 | 20 | type Drifter struct { 21 | Logger *zap.Logger 22 | Repo string 23 | Cloner *gogit.Cloner 24 | GithubClient gogithub.GitHub 25 | Terraform *terraform.Client 26 | Notification notification.Notification 27 | AtlantisClient *atlantis.Client 28 | ResultCache processedcache.ProcessedCache 29 | CacheValidDuration time.Duration 30 | DirectoryWhitelist []string 31 | SkipWorkspaceCheck bool 32 | ParallelRuns int 33 | } 34 | 35 | func (d *Drifter) Drift(ctx context.Context) error { 36 | repo, err := atlantisgithub.CheckOutTerraformRepo(ctx, d.GithubClient, d.Cloner, d.Repo) 37 | if err != nil { 38 | return fmt.Errorf("failed to checkout repo %s: %w", d.Repo, err) 39 | } 40 | d.Terraform.Directory = repo.Location() 41 | defer func() { 42 | if err := os.RemoveAll(repo.Location()); err != nil { 43 | d.Logger.Warn("failed to cleanup repo", zap.Error(err)) 44 | } 45 | }() 46 | cfg, err := atlantis.ParseRepoConfigFromDir(repo.Location()) 47 | if err != nil { 48 | return fmt.Errorf("failed to parse repo config: %w", err) 49 | } 50 | workspaces := atlantis.ConfigToWorkspaces(cfg) 51 | if err := d.FindDriftedWorkspaces(ctx, workspaces); err != nil { 52 | return fmt.Errorf("failed to find drifted workspaces: %w", err) 53 | } 54 | if err := d.FindExtraWorkspaces(ctx, workspaces); err != nil { 55 | return fmt.Errorf("failed to find extra workspaces: %w", err) 56 | } 57 | return nil 58 | } 59 | 60 | func (d *Drifter) shouldSkipDirectory(dir string) bool { 61 | if len(d.DirectoryWhitelist) == 0 { 62 | return false 63 | } 64 | for _, whitelist := range d.DirectoryWhitelist { 65 | if dir == whitelist { 66 | return false 67 | } 68 | } 69 | return true 70 | } 71 | 72 | type errFunc func(ctx context.Context) error 73 | 74 | func (d *Drifter) drainAndExecute(ctx context.Context, toRun []errFunc) error { 75 | if d.ParallelRuns <= 1 { 76 | for _, r := range toRun { 77 | if err := r(ctx); err != nil { 78 | return err 79 | } 80 | } 81 | return nil 82 | } 83 | from := make(chan errFunc) 84 | eg, egctx := errgroup.WithContext(ctx) 85 | eg.Go(func() error { 86 | for _, r := range toRun { 87 | select { 88 | case from <- r: 89 | case <-egctx.Done(): 90 | return egctx.Err() 91 | } 92 | } 93 | close(from) 94 | return nil 95 | }) 96 | for i := 0; i < d.ParallelRuns; i++ { 97 | eg.Go(func() error { 98 | for { 99 | select { 100 | case <-egctx.Done(): 101 | return egctx.Err() 102 | case r, ok := <-from: 103 | if !ok { 104 | return nil 105 | } 106 | if err := r(egctx); err != nil { 107 | return err 108 | } 109 | } 110 | } 111 | }) 112 | } 113 | return eg.Wait() 114 | } 115 | 116 | func (d *Drifter) FindDriftedWorkspaces(ctx context.Context, ws atlantis.DirectoriesWithWorkspaces) error { 117 | runningFunc := func(dir string) errFunc { 118 | return func(ctx context.Context) error { 119 | if d.shouldSkipDirectory(dir) { 120 | d.Logger.Info("Skipping directory", zap.String("dir", dir)) 121 | return nil 122 | } 123 | workspaces := ws[dir] 124 | d.Logger.Info("Checking for drifted workspaces", zap.String("dir", dir)) 125 | for _, workspace := range workspaces { 126 | cacheKey := &processedcache.ConsiderDriftChecked{ 127 | Dir: dir, 128 | Workspace: workspace, 129 | } 130 | cacheVal, err := d.ResultCache.GetDriftCheckResult(ctx, cacheKey) 131 | if err != nil { 132 | return fmt.Errorf("failed to get cache value for %s/%s: %w", dir, workspace, err) 133 | } 134 | if cacheVal != nil { 135 | if time.Since(cacheVal.When) < d.CacheValidDuration { 136 | d.Logger.Info("Skipping workspace, already checked", zap.String("dir", dir), zap.String("workspace", workspace)) 137 | continue 138 | } 139 | d.Logger.Info("Cache expired, checking again", zap.String("dir", dir), zap.String("workspace", workspace), zap.Duration("cache-age", time.Since(cacheVal.When)), zap.Duration("cache-valid-duration", d.CacheValidDuration)) 140 | if err := d.ResultCache.DeleteDriftCheckResult(ctx, cacheKey); err != nil { 141 | return fmt.Errorf("failed to delete cache value for %s/%s: %w", dir, workspace, err) 142 | } 143 | } 144 | 145 | pr, err := d.AtlantisClient.PlanSummary(ctx, &atlantis.PlanSummaryRequest{ 146 | Repo: d.Repo, 147 | Ref: "master", 148 | Type: "Github", 149 | Dir: dir, 150 | Workspace: workspace, 151 | }) 152 | if err != nil { 153 | var tmp atlantis.TemporaryError 154 | if errors.As(err, &tmp) && tmp.Temporary() { 155 | d.Logger.Warn("Temporary error. Will try again later.", zap.Error(err)) 156 | continue 157 | } 158 | return fmt.Errorf("failed to get plan summary for (%s#%s): %w", dir, workspace, err) 159 | } 160 | if err := d.ResultCache.StoreDriftCheckResult(ctx, cacheKey, &processedcache.DriftCheckValue{ 161 | When: time.Now(), 162 | Error: "", 163 | Drift: pr.HasChanges(), 164 | }); err != nil { 165 | return fmt.Errorf("failed to store cache value for %s/%s: %w", dir, workspace, err) 166 | } 167 | if pr.IsLocked() { 168 | d.Logger.Info("Plan is locked, skipping drift check", zap.String("dir", dir)) 169 | continue 170 | } 171 | if pr.HasChanges() { 172 | if err := d.Notification.PlanDrift(ctx, dir, workspace); err != nil { 173 | return fmt.Errorf("failed to notify of plan drift in %s: %w", dir, err) 174 | } 175 | } 176 | } 177 | return nil 178 | } 179 | } 180 | runs := make([]errFunc, 0) 181 | for _, dir := range ws.SortedKeys() { 182 | runs = append(runs, runningFunc(dir)) 183 | } 184 | return d.drainAndExecute(ctx, runs) 185 | } 186 | 187 | func (d *Drifter) FindExtraWorkspaces(ctx context.Context, ws atlantis.DirectoriesWithWorkspaces) error { 188 | if d.SkipWorkspaceCheck { 189 | return nil 190 | } 191 | runFunc := func(dir string) errFunc { 192 | return func(ctx context.Context) error { 193 | if d.shouldSkipDirectory(dir) { 194 | d.Logger.Info("Skipping directory", zap.String("dir", dir)) 195 | return nil 196 | } 197 | cacheKey := &processedcache.ConsiderWorkspacesChecked{ 198 | Dir: dir, 199 | } 200 | cacheVal, err := d.ResultCache.GetRemoteWorkspaces(ctx, cacheKey) 201 | if err != nil { 202 | return fmt.Errorf("failed to get cache value for %s: %w", dir, err) 203 | } 204 | if cacheVal != nil { 205 | if time.Since(cacheVal.When) < d.CacheValidDuration { 206 | d.Logger.Info("Skipping directory, in cache", zap.String("dir", dir)) 207 | return nil 208 | } 209 | d.Logger.Info("Cache expired, checking again", zap.String("dir", dir), zap.Duration("cache-age", time.Since(cacheVal.When)), zap.Duration("cache-valid-duration", d.CacheValidDuration)) 210 | if err := d.ResultCache.DeleteRemoteWorkspaces(ctx, cacheKey); err != nil { 211 | return fmt.Errorf("failed to delete cache value for %s: %w", dir, err) 212 | } 213 | } 214 | workspaces := ws[dir] 215 | d.Logger.Info("Checking for extra workspaces", zap.String("dir", dir)) 216 | if err := d.Terraform.Init(ctx, dir); err != nil { 217 | return fmt.Errorf("failed to init workspace %s: %w", dir, err) 218 | } 219 | var expectedWorkspaces []string 220 | expectedWorkspaces = append(expectedWorkspaces, workspaces...) 221 | expectedWorkspaces = append(expectedWorkspaces, "default") 222 | remoteWorkspaces, err := d.Terraform.ListWorkspaces(ctx, dir) 223 | if err != nil { 224 | return fmt.Errorf("failed to list workspaces in %s: %w", dir, err) 225 | } 226 | for _, w := range remoteWorkspaces { 227 | if !contains(expectedWorkspaces, w) { 228 | if err := d.Notification.ExtraWorkspaceInRemote(ctx, dir, w); err != nil { 229 | return fmt.Errorf("failed to notify of extra workspace %s in %s: %w", w, dir, err) 230 | } 231 | } 232 | } 233 | if err := d.ResultCache.StoreRemoteWorkspaces(ctx, cacheKey, &processedcache.WorkspacesCheckedValue{ 234 | Workspaces: remoteWorkspaces, 235 | When: time.Now(), 236 | }); err != nil { 237 | return fmt.Errorf("failed to store cache value for %s: %w", dir, err) 238 | } 239 | return nil 240 | } 241 | } 242 | runs := make([]errFunc, 0) 243 | for _, dir := range ws.SortedKeys() { 244 | runs = append(runs, runFunc(dir)) 245 | } 246 | return d.drainAndExecute(ctx, runs) 247 | } 248 | 249 | func contains(workspaces []string, w string) bool { 250 | for _, workspace := range workspaces { 251 | if workspace == w { 252 | return true 253 | } 254 | } 255 | return false 256 | } 257 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | code.gitea.io/sdk/gitea v0.21.0 h1:69n6oz6kEVHRo1+APQQyizkhrZrLsTLXey9142pfkD4= 3 | code.gitea.io/sdk/gitea v0.21.0/go.mod h1:tnBjVhuKJCn8ibdyyhvUyxrR1Ca2KHEoTWoukNhXQPA= 4 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 5 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 6 | github.com/42wim/httpsig v1.2.2 h1:ofAYoHUNs/MJOLqQ8hIxeyz2QxOz8qdSVvp3PX/oPgA= 7 | github.com/42wim/httpsig v1.2.2/go.mod h1:P/UYo7ytNBFwc+dg35IubuAUIs8zj5zzFIgUCEl55WY= 8 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 9 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 10 | github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= 11 | github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 12 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 13 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 14 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 15 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 16 | github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 17 | github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= 18 | github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 19 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= 20 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 21 | github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA= 22 | github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g= 23 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 24 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 25 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 26 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 27 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 28 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 29 | github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= 30 | github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= 31 | github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8= 32 | github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI= 33 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE= 34 | github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY= 35 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29 h1:dQFhl5Bnl/SK1EVpgElK5dckAE+lMHXnl5WCeRvNEG0= 36 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.20.29/go.mod h1:BtBP1TCx5BTCh1uTVXpo3b/odnRECBpZdL5oHQarJJs= 37 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= 38 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= 39 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= 40 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= 41 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= 42 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= 43 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 44 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= 45 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5 h1:mSBrQCXMjEvLHsYyJVbN8QQlcITXwHEuu+8mX9e2bSo= 46 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.53.5/go.mod h1:eEuD0vTf9mIzsSjGBFWIaNQwtH5/mzViJOVQfnMY5DE= 47 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9 h1:mB79k/ZTxQL4oDPxLAf2rhcUEvXlHkj3loGA2O9xREk= 48 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.32.9/go.mod h1:wXQmLDkBNh60jxAaRldON9poacv+GiSIBw/kRuT/mtE= 49 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= 50 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= 51 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16 h1:8g4OLy3zfNzLV20wXmZgx+QumI9WhWHnd4GCdvETxs4= 52 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.11.16/go.mod h1:5a78jwLMs7BaesU0UIhLfVy2ZmOEgOy6ewYQXKTD37Q= 53 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= 54 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= 55 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= 56 | github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= 57 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw= 58 | github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= 59 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= 60 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= 61 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= 62 | github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= 63 | github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= 64 | github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= 65 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 66 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 67 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 68 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 69 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= 70 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= 71 | github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 72 | github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 73 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0 h1:7r2rPUM04rgszMP0U1UZ1M5VoVVIlsaBSnpABfYxcQY= 74 | github.com/bradleyfalzon/ghinstallation/v2 v2.15.0/go.mod h1:PoH9Vhy82OeRFZfxsVrk3mfQhVkEzou9OOwPOsEhiXE= 75 | github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 76 | github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 77 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 78 | github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4= 79 | github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY= 80 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 81 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 82 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 83 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 84 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 85 | github.com/cresta/gogit v0.0.2 h1:2BnStBNrobXUMMHBirDWdI+Qsn9zPdX+techdazJ6vY= 86 | github.com/cresta/gogit v0.0.2/go.mod h1:01jDOWO3EkvsL+ybYMWeXrHbp1t4wUHrZGPfJTj7m7w= 87 | github.com/cresta/gogithub v0.2.0 h1:c7psPb9Dc2AEDAdy3w6S55W9gAYT2sJqLI/xBm9gNnk= 88 | github.com/cresta/gogithub v0.2.0/go.mod h1:Tzu4x05pGwDVxcJNAWbz0e/eH83jxG8g4gdTf8zDwGs= 89 | github.com/cresta/pipe v0.0.1 h1:LsAAmKqt0CLQ6OJgTcmk5xCAHlX+VFLE5xr/LG1OHMA= 90 | github.com/cresta/pipe v0.0.1/go.mod h1:4FI1ZPReIyNl1t+KkYVVI2a5ML+iNlO94kJ+Mk6MukE= 91 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 92 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 93 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 94 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 95 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 96 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 97 | github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= 98 | github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= 99 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 100 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 101 | github.com/drmaxgit/go-azuredevops v0.13.2 h1:wcY3X8vVkidVpILwPqUiF8xNtELpvjdv6QkNWcZyHu8= 102 | github.com/drmaxgit/go-azuredevops v0.13.2/go.mod h1:m1pO2fW60I9FahzLHMmHYq3bM446ZMZKDpd8+AEKzxc= 103 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 104 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 105 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 106 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 107 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 108 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 109 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 110 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 111 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 112 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 113 | github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 114 | github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 115 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 116 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 117 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 118 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 119 | github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60= 120 | github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k= 121 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= 122 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= 123 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 124 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 125 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 126 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 127 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 128 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 129 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= 130 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 131 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 132 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 133 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 134 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 135 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 136 | github.com/gofri/go-github-ratelimit v1.1.1 h1:5TCOtFf45M2PjSYU17txqbiYBEzjOuK1+OhivbW69W0= 137 | github.com/gofri/go-github-ratelimit v1.1.1/go.mod h1:wGZlBbzHmIVjwDR3pZgKY7RBTV6gsQWxLVkpfwhcMJM= 138 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 139 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 140 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 141 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 142 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 143 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 144 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 145 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 146 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 147 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 148 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 149 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 150 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 151 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 152 | github.com/google/go-github/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= 153 | github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= 154 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 155 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 156 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 157 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 158 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 159 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 160 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 161 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 162 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 163 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 164 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 165 | github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 166 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 167 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 168 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 169 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 170 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 171 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 172 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 173 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 174 | github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= 175 | github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= 176 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 177 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 178 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 179 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 180 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 181 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 182 | github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= 183 | github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= 184 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 185 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 186 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 187 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 188 | github.com/hashicorp/hc-install v0.9.2 h1:v80EtNX4fCVHqzL9Lg/2xkp62bbvQMnvPQ0G+OmtO24= 189 | github.com/hashicorp/hc-install v0.9.2/go.mod h1:XUqBQNnuT4RsxoxiM9ZaUk0NX8hi2h+Lb6/c0OZnC/I= 190 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 191 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 192 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= 193 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 194 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250828155816-225c06ed5fd9 h1:1MLunZqjVd9j5gc5kjE04VEoieDVdWdgdM6T2fNQvY8= 195 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250828155816-225c06ed5fd9/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= 196 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 197 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 198 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 199 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 200 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 201 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 202 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 203 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppWLm4yqBwZsKJlAayUu5FGozhrF3ETSM= 204 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHur0g8VplbLOv5vXmDzacSaH9Z7XhcgsSh1xciU= 205 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 206 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 207 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 208 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 209 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 210 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 211 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= 212 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 213 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 214 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 215 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 216 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 217 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 218 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 219 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 220 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 221 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 222 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 223 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 224 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 225 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 226 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 227 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 228 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 229 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 230 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 231 | github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= 232 | github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= 233 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 234 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 235 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 236 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 237 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 238 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 239 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 240 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 241 | github.com/nlopes/slack v0.6.0 h1:jt0jxVQGhssx1Ib7naAOZEZcGdtIhTzkP0nopK0AsRA= 242 | github.com/nlopes/slack v0.6.0/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= 243 | github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= 244 | github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 245 | github.com/opentofu/tofudl v0.0.1 h1:r2uD4nxMnq0Qkzhh/C9Ldxjt+piTJi0R0C40Kf4d+a8= 246 | github.com/opentofu/tofudl v0.0.1/go.mod h1:HeIabsnOzo0WMnIRqI13Ho6hEi6tu2nrQpzSddWL/9w= 247 | github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= 248 | github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= 249 | github.com/petergtz/pegomock/v4 v4.2.0 h1:qL2AXgE6JaajLWled2ixupk1EAW3gjT9HowZdI5yLfY= 250 | github.com/petergtz/pegomock/v4 v4.2.0/go.mod h1:MWuKPa+Q58c+MtwRQKimUzOdOmrDMV71BOzYB7y0ukI= 251 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 252 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 253 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 254 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 255 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 256 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 257 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 258 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 259 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 260 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 261 | github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= 262 | github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= 263 | github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ= 264 | github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ= 265 | github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= 266 | github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= 267 | github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 268 | github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 269 | github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= 270 | github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= 271 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 272 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 273 | github.com/runatlantis/atlantis v0.36.0 h1:Y4xSzT5qpRTWMHVvyOazT+h3zH1faRkgqi8cXDFmtYg= 274 | github.com/runatlantis/atlantis v0.36.0/go.mod h1:JR8WASwGUhuuSsMOwnfLDFBulFXx8nZfzBCXUI0V9F4= 275 | github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo= 276 | github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k= 277 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 278 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 279 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 280 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 281 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= 282 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 283 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 284 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 285 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 286 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 287 | github.com/slack-go/slack v0.16.0 h1:khp/WCFv+Hb/B/AJaAwvcxKun0hM6grN0bUZ8xG60P8= 288 | github.com/slack-go/slack v0.16.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 289 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 290 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 291 | github.com/spf13/afero v1.12.0 h1:UcOPyRBYczmFn6yvphxkn9ZEOY65cpwGKb5mL36mrqs= 292 | github.com/spf13/afero v1.12.0/go.mod h1:ZTlWwG4/ahT8W7T0WQ5uYmjI9duaLQGy3Q2OAl4sk/4= 293 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 294 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 295 | github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= 296 | github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 297 | github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= 298 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 299 | github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= 300 | github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= 301 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 302 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 303 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 304 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 305 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 306 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 307 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 308 | github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= 309 | github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= 310 | github.com/uber-go/tally/v4 v4.1.17 h1:C+U4BKtVDXTszuzU+WH8JVQvRVnaVKxzZrROFyDrvS8= 311 | github.com/uber-go/tally/v4 v4.1.17/go.mod h1:ZdpiHRGSa3z4NIAc1VlEH4SiknR885fOIF08xmS0gaU= 312 | github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= 313 | github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 314 | github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= 315 | github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= 316 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 317 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 318 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 319 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 320 | github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= 321 | github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 322 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 323 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 324 | gitlab.com/gitlab-org/api/client-go v0.118.0 h1:qHIEw+XHt+2xuk4iZGW8fc6t+gTLAGEmTA5Bzp/brxs= 325 | gitlab.com/gitlab-org/api/client-go v0.118.0/go.mod h1:E+X2dndIYDuUfKVP0C3jhkWvTSE00BkLbCsXTY3edDo= 326 | go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo= 327 | go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= 328 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 329 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 330 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 331 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 332 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 333 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 334 | go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= 335 | go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 336 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 337 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 338 | golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 339 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 340 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 341 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 342 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 343 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 344 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 345 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 346 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 347 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 348 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 349 | golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 350 | golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 351 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 352 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 353 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 354 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 355 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 356 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 357 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 358 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 359 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 360 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 361 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 362 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 363 | golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 364 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 365 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 366 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 367 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 368 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 369 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 370 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 371 | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 372 | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 373 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 374 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 375 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 376 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 377 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 378 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 379 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 380 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 381 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 382 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 383 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 384 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 385 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 386 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 387 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 388 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 389 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 390 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 391 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 392 | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 393 | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 394 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 395 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 396 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 397 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 398 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 399 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 400 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 401 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 402 | golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= 403 | golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 404 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 405 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 406 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 407 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 408 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 409 | golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 410 | golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 411 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 412 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 413 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 414 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 415 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 416 | google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= 417 | google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 418 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 419 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 420 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 421 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 422 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 423 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 424 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 425 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 426 | --------------------------------------------------------------------------------