├── .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.2 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/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 | -------------------------------------------------------------------------------- /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 | // Updated errorResponse to handle dynamic error types 70 | type errorResponse struct { 71 | Error interface{} `json:"error"` // Allow both string and structured errors 72 | } 73 | 74 | func (p *possiblyTemporaryError) Temporary() bool { 75 | return true 76 | } 77 | 78 | func (c *Client) PlanSummary(ctx context.Context, req *PlanSummaryRequest) (*PlanResult, error) { 79 | planBody := controllers.APIRequest{ 80 | Repository: req.Repo, 81 | Ref: req.Ref, 82 | Type: req.Type, 83 | Paths: []struct { 84 | Directory string 85 | Workspace string 86 | }{ 87 | { 88 | Directory: req.Dir, 89 | Workspace: req.Workspace, 90 | }, 91 | }, 92 | } 93 | planBodyJSON, err := json.Marshal(planBody) 94 | if err != nil { 95 | return nil, fmt.Errorf("error marshalling plan body: %w", err) 96 | } 97 | destination := fmt.Sprintf("%s/api/plan", c.AtlantisHostname) 98 | httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, destination, strings.NewReader(string(planBodyJSON))) 99 | if err != nil { 100 | return nil, fmt.Errorf("error parsing destination: %w", err) 101 | } 102 | httpReq.Header.Set("X-Atlantis-Token", c.Token) 103 | httpReq = httpReq.WithContext(ctx) 104 | 105 | resp, err := c.HTTPClient.Do(httpReq) 106 | if err != nil { 107 | return nil, fmt.Errorf("error making plan request to %s: %w", destination, err) 108 | } 109 | var fullBody bytes.Buffer 110 | if _, err := io.Copy(&fullBody, resp.Body); err != nil { 111 | return nil, fmt.Errorf("unable to read response body: %w", err) 112 | } 113 | if err := resp.Body.Close(); err != nil { 114 | return nil, fmt.Errorf("unable to close response body: %w", err) 115 | } 116 | 117 | if resp.StatusCode == http.StatusUnauthorized { 118 | var errResp errorResponse 119 | if err := json.NewDecoder(&fullBody).Decode(&errResp); err != nil { 120 | return nil, fmt.Errorf("unauthorized request to %s: %w", destination, err) 121 | } 122 | 123 | // Handle different error types dynamically 124 | switch errResp.Error := errResp.Error.(type) { 125 | case string: 126 | // Simple string error 127 | return nil, fmt.Errorf("unauthorized request to %s: %s", destination, errResp.Error) 128 | case map[string]interface{}: 129 | // Structured error object 130 | return nil, fmt.Errorf("unauthorized request to %s: %v", destination, errResp.Error) 131 | default: 132 | // Unknown error format 133 | return nil, fmt.Errorf("unknown error format in response: %v", errResp.Error) 134 | } 135 | } 136 | 137 | var bodyResult command.Result 138 | if err := json.NewDecoder(&fullBody).Decode(&bodyResult); err != nil { 139 | retErr := fmt.Errorf("error decoding plan response(code:%d)(status:%s)(body:%s): %w", resp.StatusCode, resp.Status, fullBody.String(), err) 140 | if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusInternalServerError { 141 | // Handle temporary errors from Atlantis 142 | return nil, &possiblyTemporaryError{retErr} 143 | } 144 | return nil, retErr 145 | } 146 | 147 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusInternalServerError { 148 | return nil, fmt.Errorf("non-200 and non-500 response for %s: %d", destination, resp.StatusCode) 149 | } 150 | 151 | // Check for errors in bodyResult 152 | if bodyResult.Error != nil { 153 | return nil, fmt.Errorf("error making plan request: %w", bodyResult.Error) 154 | } 155 | if bodyResult.Failure != "" { 156 | return nil, fmt.Errorf("failure making plan request: %s", bodyResult.Failure) 157 | } 158 | 159 | // Process project results 160 | var ret PlanResult 161 | for _, result := range bodyResult.ProjectResults { 162 | if result.Failure != "" { 163 | if strings.Contains(result.Failure, "This project is currently locked ") { 164 | ret.Summaries = append(ret.Summaries, PlanSummary{HasLock: true}) 165 | continue 166 | } 167 | } 168 | if result.PlanSuccess != nil { 169 | summary := result.PlanSuccess.Summary() 170 | ret.Summaries = append(ret.Summaries, PlanSummary{Summary: summary}) 171 | continue 172 | } 173 | return nil, fmt.Errorf("project result unknown failure: %s", result.Failure) 174 | } 175 | 176 | return &ret, nil 177 | } 178 | -------------------------------------------------------------------------------- /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.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/aws/aws-sdk-go-v2/config v1.29.14 9 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.12 10 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.4 11 | github.com/cresta/gogit v0.0.2 12 | github.com/cresta/gogithub v0.2.0 13 | github.com/cresta/pipe v0.0.1 14 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd 15 | github.com/joho/godotenv v1.5.1 16 | github.com/nlopes/slack v0.6.0 17 | github.com/runatlantis/atlantis v0.34.0 18 | github.com/stretchr/testify v1.10.0 19 | go.uber.org/zap v1.27.0 20 | golang.org/x/sync v0.13.0 21 | gopkg.in/yaml.v3 v3.0.1 22 | ) 23 | 24 | require ( 25 | code.gitea.io/sdk/gitea v0.19.0 // indirect 26 | dario.cat/mergo v1.0.1 // indirect 27 | github.com/Masterminds/goutils v1.1.1 // indirect 28 | github.com/Masterminds/semver/v3 v3.3.0 // indirect 29 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 30 | github.com/ProtonMail/go-crypto v1.1.3 // indirect 31 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f // indirect 32 | github.com/ProtonMail/gopenpgp/v2 v2.7.5 // indirect 33 | github.com/agext/levenshtein v1.2.3 // indirect 34 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 35 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect 36 | github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect 37 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect 38 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect 39 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect 40 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect 41 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect 42 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.3 // indirect 43 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect 44 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15 // indirect 45 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect 46 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect 47 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect 48 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 // indirect 49 | github.com/aws/smithy-go v1.22.2 // 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.13.0 // indirect 53 | github.com/cactus/go-statsd-client/v5 v5.1.0 // indirect 54 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 55 | github.com/cloudflare/circl v1.3.9 // 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.1 // indirect 59 | github.com/gabriel-vasile/mimetype v1.4.3 // 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.23.0 // indirect 65 | github.com/gofri/go-github-ratelimit v1.1.0 // 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.6.0 // indirect 69 | github.com/google/go-github/v68 v68.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.1 // 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-20250203082807-efaa306e97b4 // 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.0-20250129123822-d4254f2a6147 // 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.15.0 // indirect 106 | github.com/spf13/cast v1.7.0 // indirect 107 | github.com/spf13/pflag v1.0.5 // indirect 108 | github.com/twmb/murmur3 v1.1.8 // indirect 109 | github.com/uber-go/tally/v4 v4.1.16 // 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.35.0 // indirect 115 | golang.org/x/mod v0.22.0 // indirect 116 | golang.org/x/net v0.36.0 // indirect 117 | golang.org/x/oauth2 v0.27.0 // indirect 118 | golang.org/x/sys v0.30.0 // indirect 119 | golang.org/x/text v0.22.0 // indirect 120 | golang.org/x/time v0.5.0 // indirect 121 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 122 | google.golang.org/protobuf v1.36.0 // 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.19.0 h1:8I6s1s4RHgzxiPHhOQdgim1RWIRcr0LVMbHBjBFXq4Y= 3 | code.gitea.io/sdk/gitea v0.19.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI= 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/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 7 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 8 | github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0= 9 | github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 10 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 11 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 12 | github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow= 13 | github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM= 14 | github.com/ProtonMail/go-crypto v0.0.0-20230717121422-5aa5874ade95/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= 15 | github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk= 16 | github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= 17 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f h1:tCbYj7/299ekTTXpdwKYF8eBlsYsDVoggDAuAjoK66k= 18 | github.com/ProtonMail/go-mime v0.0.0-20230322103455-7d82a3887f2f/go.mod h1:gcr0kNtGBqin9zDW9GOHcVntrwnjrK+qdJ06mWYBybw= 19 | github.com/ProtonMail/gopenpgp/v2 v2.7.5 h1:STOY3vgES59gNgoOt2w0nyHBjKViB/qSg7NjbQWPJkA= 20 | github.com/ProtonMail/gopenpgp/v2 v2.7.5/go.mod h1:IhkNEDaxec6NyzSI0PlxapinnwPVIESk8/76da3Ct3g= 21 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 22 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 23 | github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 24 | github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= 25 | github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 26 | github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= 27 | github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= 28 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 29 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 30 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= 31 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= 32 | github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM= 33 | github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg= 34 | github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= 35 | github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= 36 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= 37 | github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= 38 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.12 h1:mwAIR3fhxhSzXFj530LNCBe0JocYVQx6GuJpQiA+QOs= 39 | github.com/aws/aws-sdk-go-v2/feature/dynamodb/attributevalue v1.18.12/go.mod h1:9cWrNL8q7ApFmZzKhnb63ub4zrdMzOGQVn/kxvagfeE= 40 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= 41 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= 42 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q= 43 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY= 44 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0= 45 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q= 46 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= 47 | github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= 48 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.4 h1:5GjCSGIpndYU/tVABz+4XnAcluU6wrjlPzAAgFUDG98= 49 | github.com/aws/aws-sdk-go-v2/service/dynamodb v1.42.4/go.mod h1:yYaWRnVSPyAmexW5t7G3TcuYoalYfT+xQwzWsvtUQ7M= 50 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.3 h1:GHC1WTF3ZBZy+gvz2qtYB6ttALVx35hlwc4IzOIUY7g= 51 | github.com/aws/aws-sdk-go-v2/service/dynamodbstreams v1.25.3/go.mod h1:lUqWdw5/esjPTkITXhN4C66o1ltwDq2qQ12j3SOzhVg= 52 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= 53 | github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= 54 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15 h1:M1R1rud7HzDrfCdlBQ7NjnRsDNEhXO/vGhuD189Ggmk= 55 | github.com/aws/aws-sdk-go-v2/service/internal/endpoint-discovery v1.10.15/go.mod h1:uvFKBSq9yMPV4LGAi7N4awn4tLY+hKE35f8THes2mzQ= 56 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= 57 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= 58 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= 59 | github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= 60 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= 61 | github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= 62 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= 63 | github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= 64 | github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= 65 | github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 66 | github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= 67 | github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= 68 | github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= 69 | github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= 70 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 71 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 72 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d h1:xDfNPAt8lFiC1UJrqV3uuy861HCTo708pDMbjHHdCas= 73 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d/go.mod h1:6QX/PXZ00z/TKoufEY6K/a0k6AhaJrQKdFe6OfVXsa4= 74 | github.com/bmatcuk/doublestar/v4 v4.8.1 h1:54Bopc5c2cAvhLRAzqOGCYHYyhcDHsFF4wWIR5wKP38= 75 | github.com/bmatcuk/doublestar/v4 v4.8.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= 76 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0 h1:5FhjW93/YLQJDmPdeyMPw7IjAPzqsr+0jHPfrPz0sZI= 77 | github.com/bradleyfalzon/ghinstallation/v2 v2.13.0/go.mod h1:EJ6fgedVEHa2kUyBTTvslJCXJafS/mhJNNKEOCspZXQ= 78 | github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= 79 | github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= 80 | github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= 81 | github.com/cactus/go-statsd-client/v5 v5.0.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY= 82 | github.com/cactus/go-statsd-client/v5 v5.1.0 h1:sbbdfIl9PgisjEoXzvXI1lwUKWElngsjJKaZeC021P4= 83 | github.com/cactus/go-statsd-client/v5 v5.1.0/go.mod h1:COEvJ1E+/E2L4q6QE5CkjWPi4eeDw9maJBMIuMPBZbY= 84 | github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 85 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 86 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 87 | github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= 88 | github.com/cloudflare/circl v1.3.9 h1:QFrlgFYf2Qpi8bSpVPK1HBvWpx16v/1TZivyo7pGuBE= 89 | github.com/cloudflare/circl v1.3.9/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= 90 | github.com/cresta/gogit v0.0.2 h1:2BnStBNrobXUMMHBirDWdI+Qsn9zPdX+techdazJ6vY= 91 | github.com/cresta/gogit v0.0.2/go.mod h1:01jDOWO3EkvsL+ybYMWeXrHbp1t4wUHrZGPfJTj7m7w= 92 | github.com/cresta/gogithub v0.2.0 h1:c7psPb9Dc2AEDAdy3w6S55W9gAYT2sJqLI/xBm9gNnk= 93 | github.com/cresta/gogithub v0.2.0/go.mod h1:Tzu4x05pGwDVxcJNAWbz0e/eH83jxG8g4gdTf8zDwGs= 94 | github.com/cresta/pipe v0.0.1 h1:LsAAmKqt0CLQ6OJgTcmk5xCAHlX+VFLE5xr/LG1OHMA= 95 | github.com/cresta/pipe v0.0.1/go.mod h1:4FI1ZPReIyNl1t+KkYVVI2a5ML+iNlO94kJ+Mk6MukE= 96 | github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo= 97 | github.com/cyphar/filepath-securejoin v0.2.5/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= 98 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 99 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 100 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 101 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 102 | github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= 103 | github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= 104 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 105 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 106 | github.com/drmaxgit/go-azuredevops v0.13.1 h1:mqRxiwnbKraHAtqfTFa+xD75Atf84MgpjKJdA8hJeS4= 107 | github.com/drmaxgit/go-azuredevops v0.13.1/go.mod h1:m1pO2fW60I9FahzLHMmHYq3bM446ZMZKDpd8+AEKzxc= 108 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 109 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 110 | github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 111 | github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 112 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 113 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 114 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 115 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 116 | github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= 117 | github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= 118 | github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= 119 | github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= 120 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 121 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 122 | github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8= 123 | github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= 124 | github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= 125 | github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= 126 | github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 127 | github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= 128 | github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= 129 | github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= 130 | github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= 131 | github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A= 132 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible h1:msy24VGS42fKO9K1vLz82/GeYW1cILu7Nuuj1N3BBkE= 133 | github.com/go-ozzo/ozzo-validation v3.6.0+incompatible/go.mod h1:gsEKFIVnabGBt6mXmxK0MoFy+cZoTJY6mu5Ll3LVLBU= 134 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 135 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 136 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 137 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 138 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 139 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 140 | github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= 141 | github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 142 | github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= 143 | github.com/go-test/deep v1.0.4/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= 144 | github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= 145 | github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= 146 | github.com/gofri/go-github-ratelimit v1.1.0 h1:ijQ2bcv5pjZXNil5FiwglCg8wc9s8EgjTmNkqjw8nuk= 147 | github.com/gofri/go-github-ratelimit v1.1.0/go.mod h1:OnCi5gV+hAG/LMR7llGhU7yHt44se9sYgKPnafoL7RY= 148 | github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= 149 | github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= 150 | github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 151 | github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 152 | github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 153 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 154 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 155 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 156 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 157 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 158 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 159 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 160 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 161 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 162 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 163 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 164 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 165 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 166 | github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 167 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 168 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 169 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 170 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 171 | github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 172 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 173 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 174 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 175 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 176 | github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s= 177 | github.com/google/go-github/v68 v68.0.0/go.mod h1:K9HAUBovM2sLwM408A18h+wd9vqdLOEqTUCbnRIcx68= 178 | github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= 179 | github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= 180 | github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 181 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 182 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 183 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 184 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 185 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 186 | github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= 187 | github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 188 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 189 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 190 | github.com/gorilla/websocket v1.2.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= 191 | github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 192 | github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 193 | github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 194 | github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 195 | github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= 196 | github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= 197 | github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 198 | github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= 199 | github.com/hashicorp/go-getter/v2 v2.2.3 h1:6CVzhT0KJQHqd9b0pK3xSP0CM/Cv+bVhk+jcaRJ2pGk= 200 | github.com/hashicorp/go-getter/v2 v2.2.3/go.mod h1:hp5Yy0GMQvwWVUmwLs3ygivz1JSLI323hdIE9J9m7TY= 201 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 202 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 203 | github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= 204 | github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= 205 | github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= 206 | github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= 207 | github.com/hashicorp/go-safetemp v1.0.0 h1:2HR189eFNrjHQyENnQMMpCiBAsRxzbTMIgBhEyExpmo= 208 | github.com/hashicorp/go-safetemp v1.0.0/go.mod h1:oaerMy3BhqiTbVye6QuFhFtIceqFoDHxNAB65b+Rj1I= 209 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 210 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 211 | github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 212 | github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 213 | github.com/hashicorp/hc-install v0.9.1 h1:gkqTfE3vVbafGQo6VZXcy2v5yoz2bE0+nhZXruCuODQ= 214 | github.com/hashicorp/hc-install v0.9.1/go.mod h1:pWWvN/IrfeBK4XPeXXYkL6EjMufHkCK5DvwxeLKuBf0= 215 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 216 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 217 | github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos= 218 | github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 219 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250203082807-efaa306e97b4 h1:6zYoI+NGpRPo0UjbnJfmqqTFcTEKvbv77h0ZcgeLXJs= 220 | github.com/hashicorp/terraform-config-inspect v0.0.0-20250203082807-efaa306e97b4/go.mod h1:Gz/z9Hbn+4KSp8A2FBtNszfLSdT2Tn/uAKGuVqqWmDI= 221 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 222 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 223 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 224 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 225 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 226 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 227 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 228 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd h1:nIzoSW6OhhppWLm4yqBwZsKJlAayUu5FGozhrF3ETSM= 229 | github.com/joeshaw/envdecode v0.0.0-20200121155833-099f1fc765bd/go.mod h1:MEQrHur0g8VplbLOv5vXmDzacSaH9Z7XhcgsSh1xciU= 230 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 231 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 232 | github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= 233 | github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= 234 | github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= 235 | github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 236 | github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= 237 | github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= 238 | github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 239 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 240 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 241 | github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= 242 | github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 243 | github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 244 | github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= 245 | github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= 246 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 247 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 248 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 249 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 250 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 251 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 252 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 253 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 254 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 255 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 256 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 257 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 258 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 259 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 260 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 261 | github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= 262 | github.com/mcdafydd/go-azuredevops v0.12.1 h1:WxwLVyGuJ8oL7uWQp1/J6GefX1wMQQZUHWRGsrm+uE8= 263 | github.com/mcdafydd/go-azuredevops v0.12.1/go.mod h1:B4UDyn7WEj1/97f45j3VnzEfkWKe05+/dCcAPdOET4A= 264 | github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= 265 | github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= 266 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 267 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 268 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 269 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 270 | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= 271 | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= 272 | github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= 273 | github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= 274 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 275 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 276 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 277 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 278 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 279 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 280 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= 281 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 282 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 283 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 284 | github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 285 | github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= 286 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= 287 | github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= 288 | github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 289 | github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= 290 | github.com/nlopes/slack v0.6.0 h1:jt0jxVQGhssx1Ib7naAOZEZcGdtIhTzkP0nopK0AsRA= 291 | github.com/nlopes/slack v0.6.0/go.mod h1:JzQ9m3PMAqcpeCam7UaHSuBuupz7CmpjehYMayT6YOk= 292 | github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= 293 | github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= 294 | github.com/opentofu/tofudl v0.0.0-20250129123822-d4254f2a6147 h1:FRrWXOEB5P/rmDbXNON3erWlAxe8NtxEeAfYqMk0oRY= 295 | github.com/opentofu/tofudl v0.0.0-20250129123822-d4254f2a6147/go.mod h1:HeIabsnOzo0WMnIRqI13Ho6hEi6tu2nrQpzSddWL/9w= 296 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 297 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 298 | github.com/petergtz/pegomock/v4 v4.1.0 h1:Reoy2rlwshuxNaD2ZWp5TrSCrmoFH5SSLHb5U1z2pog= 299 | github.com/petergtz/pegomock/v4 v4.1.0/go.mod h1:Xscaw/kXYcuh9sGsns+If19FnSMMQy4Wz60YJTn3XOU= 300 | github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= 301 | github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= 302 | github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 303 | github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 304 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 305 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 306 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 307 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 308 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 309 | github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= 310 | github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= 311 | github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= 312 | github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= 313 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 314 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 315 | github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= 316 | github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 317 | github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 318 | github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= 319 | github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= 320 | github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= 321 | github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= 322 | github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= 323 | github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ= 324 | github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ= 325 | github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= 326 | github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= 327 | github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= 328 | github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= 329 | github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= 330 | github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= 331 | github.com/redis/go-redis/v9 v9.7.3 h1:YpPyAayJV+XErNsatSElgRZZVCwXX9QzkKYNvO7x0wM= 332 | github.com/redis/go-redis/v9 v9.7.3/go.mod h1:bGUrSggJ9X9GUmZpZNEOQKaANxSGgOEBRltRTZHSvrA= 333 | github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E= 334 | github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= 335 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 336 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 337 | github.com/runatlantis/atlantis v0.34.0 h1:tiNbvhm2yrbFFEYHKGbr0vd+jeroux7nlvTKeRH0iFA= 338 | github.com/runatlantis/atlantis v0.34.0/go.mod h1:d1CuE1FiRidtvGt75TeE3DA4gFAWF/umW4R4IH2eILU= 339 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 340 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 341 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 342 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 343 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= 344 | github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 345 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 346 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 347 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 h1:cYCy18SHPKRkvclm+pWm1Lk4YrREb4IOIb/YdFO0p2M= 348 | github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7/go.mod h1:zqMwyHmnN/eDOZOdiTohqIUKUrTFX62PNlu7IJdu0q8= 349 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 h1:17JxqqJY66GmZVHkmAsGEkcIu0oCe3AM420QDgGwZx0= 350 | github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466/go.mod h1:9dIRpgIY7hVhoqfe0/FcYp0bpInZaT7dc3BYOprrIUE= 351 | github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= 352 | github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= 353 | github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88= 354 | github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= 355 | github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= 356 | github.com/slack-go/slack v0.15.0 h1:LE2lj2y9vqqiOf+qIIy0GvEoxgF1N5yLGZffmEZykt0= 357 | github.com/slack-go/slack v0.15.0/go.mod h1:hlGi5oXA+Gt+yWTPP0plCdRKmjsDxecdHxYQdlMQKOw= 358 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 359 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 360 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 361 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 362 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 363 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 364 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 365 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 366 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 367 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 368 | github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= 369 | github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg= 370 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 371 | github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 372 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 373 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 374 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 375 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 376 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 377 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 378 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 379 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 380 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 381 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 382 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 383 | github.com/twmb/murmur3 v1.1.5/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= 384 | github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg= 385 | github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ= 386 | github.com/uber-go/tally/v4 v4.1.16 h1:by2hveWRh/cUReButk6ns1sHK/hiKry7BuOV6iY16XI= 387 | github.com/uber-go/tally/v4 v4.1.16/go.mod h1:RW5DgqsyEPs0lA4b0YNf4zKj7DveKHd73hnO6zVlyW0= 388 | github.com/ulikunitz/xz v0.5.11 h1:kpFauv27b6ynzBNT/Xy+1k+fK4WswhN/6PN5WhFAGw8= 389 | github.com/ulikunitz/xz v0.5.11/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= 390 | github.com/urfave/negroni/v3 v3.1.1 h1:6MS4nG9Jk/UuCACaUlNXCbiKa0ywF9LXz5dGu09v8hw= 391 | github.com/urfave/negroni/v3 v3.1.1/go.mod h1:jWvnX03kcSjDBl/ShB0iHvx5uOs7mAzZXW+JvJ5XYAs= 392 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 393 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 394 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 395 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 396 | github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= 397 | github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 398 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= 399 | github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= 400 | gitlab.com/gitlab-org/api/client-go v0.118.0 h1:qHIEw+XHt+2xuk4iZGW8fc6t+gTLAGEmTA5Bzp/brxs= 401 | gitlab.com/gitlab-org/api/client-go v0.118.0/go.mod h1:E+X2dndIYDuUfKVP0C3jhkWvTSE00BkLbCsXTY3edDo= 402 | go.etcd.io/bbolt v1.3.11 h1:yGEzV1wPz2yVCLsD8ZAiGHhHVlczyC9d1rP43/VCRJ0= 403 | go.etcd.io/bbolt v1.3.11/go.mod h1:dksAq7YMXoljX0xu6VF5DMZGbhYYoLUalEiSySYAS4I= 404 | go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= 405 | go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= 406 | go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= 407 | go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= 408 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 409 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 410 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 411 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 412 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 413 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 414 | golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= 415 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 416 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 417 | golang.org/x/crypto v0.0.0-20200403201458-baeed622b8d8/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 418 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 419 | golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= 420 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 421 | golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= 422 | golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= 423 | golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs= 424 | golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= 425 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 426 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 427 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 428 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 429 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 430 | golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 431 | golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 432 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 433 | golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 434 | golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 435 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 436 | golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 437 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 438 | golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= 439 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 440 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 441 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 442 | golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 443 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 444 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 445 | golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA= 446 | golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I= 447 | golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 448 | golang.org/x/oauth2 v0.0.0-20190402181905-9f3314589c9a/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 449 | golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M= 450 | golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 451 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 452 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 453 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 454 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 455 | golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 456 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 457 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 458 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 459 | golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610= 460 | golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 461 | golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 462 | golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 463 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 464 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 465 | golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 466 | golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 467 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 468 | golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 469 | golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 470 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 471 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 472 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 473 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 474 | golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 475 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 476 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 477 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 478 | golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 479 | golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 480 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 481 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 482 | golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= 483 | golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 484 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 485 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 486 | golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 487 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 488 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 489 | golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= 490 | golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 491 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 492 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= 493 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 494 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 495 | golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 496 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 497 | golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 498 | golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= 499 | golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 500 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 501 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 502 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 503 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 504 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 505 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 506 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 507 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 508 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 509 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 510 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 511 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 512 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 513 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 514 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 515 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 516 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 517 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 518 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 519 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 520 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 521 | google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= 522 | google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 523 | gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= 524 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 525 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 526 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 527 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 528 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 529 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 530 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 531 | gopkg.in/validator.v2 v2.0.0-20200605151824-2b28d334fa05/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc= 532 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 533 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 534 | gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 535 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 536 | gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 537 | gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 538 | gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 539 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 540 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 541 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 542 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 543 | --------------------------------------------------------------------------------