diff --git a/github/provider.go b/github/provider.go index 8f44c95098..2a5dd31102 100644 --- a/github/provider.go +++ b/github/provider.go @@ -128,6 +128,7 @@ func Provider() *schema.Provider { "github_enterprise_actions_permissions": resourceGithubActionsEnterprisePermissions(), "github_actions_environment_secret": resourceGithubActionsEnvironmentSecret(), "github_actions_environment_variable": resourceGithubActionsEnvironmentVariable(), + "github_actions_environment_variables": resourceGithubActionsEnvironmentVariables(), "github_actions_organization_oidc_subject_claim_customization_template": resourceGithubActionsOrganizationOIDCSubjectClaimCustomizationTemplate(), "github_actions_organization_permissions": resourceGithubActionsOrganizationPermissions(), "github_actions_organization_secret": resourceGithubActionsOrganizationSecret(), @@ -139,6 +140,7 @@ func Provider() *schema.Provider { "github_actions_runner_group": resourceGithubActionsRunnerGroup(), "github_actions_secret": resourceGithubActionsSecret(), "github_actions_variable": resourceGithubActionsVariable(), + "github_actions_variables": resourceGithubActionsVariables(), "github_app_installation_repositories": resourceGithubAppInstallationRepositories(), "github_app_installation_repository": resourceGithubAppInstallationRepository(), "github_branch": resourceGithubBranch(), diff --git a/github/resource_github_actions_environment_variables.go b/github/resource_github_actions_environment_variables.go new file mode 100644 index 0000000000..9821e29f99 --- /dev/null +++ b/github/resource_github_actions_environment_variables.go @@ -0,0 +1,353 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "net/url" + "sort" + "strings" + + "github.com/google/go-github/v66/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubActionsEnvironmentVariables() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubActionsEnvironmentVariablesCreate, + Read: resourceGithubActionsEnvironmentVariablesRead, + Update: resourceGithubActionsEnvironmentVariablesUpdate, + Delete: resourceGithubActionsEnvironmentVariablesDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the repository.", + }, + "environment": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the environment.", + }, + "variable": { + Type: schema.TypeSet, + Optional: true, + Description: "List of variables to manage.", + Set: func(v interface{}) int { + m := v.(map[string]interface{}) + return schema.HashString(strings.ToUpper(m["name"].(string))) + }, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the variable.", + ValidateDiagFunc: validateSecretNameFunc, + DiffSuppressFunc: caseInsensitive(), + }, + "value": { + Type: schema.TypeString, + Required: true, + Description: "Value of the variable.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of variable creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of variable update.", + }, + }, + }, + }, + }, + } +} + +type environmentVariable struct { + name string + value string + createdAt string + updatedAt string +} + +func (v environmentVariable) Empty() bool { + return v == environmentVariable{} +} + +func flattenEnvironmentVariable(variable environmentVariable) map[string]interface{} { + if variable.Empty() { + return nil + } + + return map[string]interface{}{ + "name": variable.name, + "value": variable.value, + "created_at": variable.createdAt, + "updated_at": variable.updatedAt, + } +} + +func flattenEnvironmentVariables(variables []environmentVariable) []interface{} { + if variables == nil { + return nil + } + + // Sort variables by name for consistent ordering + sort.SliceStable(variables, func(i, j int) bool { + return variables[i].name < variables[j].name + }) + + result := make([]interface{}, len(variables)) + for i, variable := range variables { + result[i] = flattenEnvironmentVariable(variable) + } + + return result +} + +// List all environment variables for a repository environment +func listEnvironmentVariables(client *github.Client, ctx context.Context, owner, repo, envName string) ([]environmentVariable, error) { + escapedEnvName := url.PathEscape(envName) + options := github.ListOptions{ + PerPage: 100, + } + + var allVariables []environmentVariable + for { + variables, resp, err := client.Actions.ListEnvVariables(ctx, owner, repo, escapedEnvName, &options) + if err != nil { + return nil, err + } + + for _, variable := range variables.Variables { + allVariables = append(allVariables, environmentVariable{ + name: variable.Name, + value: variable.Value, + createdAt: variable.CreatedAt.String(), + updatedAt: variable.UpdatedAt.String(), + }) + } + + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + return allVariables, nil +} + +// Create or update variables to match desired state +func syncEnvironmentVariables(ctx context.Context, client *github.Client, owner, repo, envName string, + wantVariables []interface{}, existingVariables []environmentVariable) error { + + escapedEnvName := url.PathEscape(envName) + + // Map of existing variables by name for easy lookup + existingMap := make(map[string]environmentVariable) + for _, v := range existingVariables { + existingMap[v.name] = v + } + + // Track variables to create, update, or delete + for _, v := range wantVariables { + varConfig := v.(map[string]interface{}) + name := strings.ToUpper(varConfig["name"].(string)) + value := varConfig["value"].(string) + + if existing, exists := existingMap[name]; exists { + // Variable exists, check if value has changed + if existing.value != value { + // Update variable + variable := &github.ActionsVariable{ + Name: name, + Value: value, + } + + _, err := client.Actions.UpdateEnvVariable(ctx, owner, repo, escapedEnvName, variable) + if err != nil { + return fmt.Errorf("error updating environment variable %s: %v", name, err) + } + log.Printf("[DEBUG] Updated environment variable: %s", name) + } + + // Remove from map to track what variables to keep + delete(existingMap, name) + } else { + // Create new variable + variable := &github.ActionsVariable{ + Name: name, + Value: value, + } + + _, err := client.Actions.CreateEnvVariable(ctx, owner, repo, escapedEnvName, variable) + if err != nil { + return fmt.Errorf("error creating environment variable %s: %v", name, err) + } + log.Printf("[DEBUG] Created environment variable: %s", name) + } + } + + // Delete variables that are no longer in config + for name := range existingMap { + _, err := client.Actions.DeleteEnvVariable(ctx, owner, repo, escapedEnvName, name) + if err != nil { + return fmt.Errorf("error deleting environment variable %s: %v", name, err) + } + log.Printf("[DEBUG] Deleted environment variable: %s", name) + } + + return nil +} + +func resourceGithubActionsEnvironmentVariablesCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + envName := d.Get("environment").(string) + variables := d.Get("variable").(*schema.Set).List() + + // Check for 100 item environment variable limit + if len(variables) > 100 { + return fmt.Errorf("environment variable set cannot contain more than 100 items") + } + + // Check for any duplicate variable names + namesMap := make(map[string]struct{}) + for _, v := range variables { + variableConfig := v.(map[string]interface{}) + name := strings.ToUpper(variableConfig["name"].(string)) + if _, exists := namesMap[name]; exists { + return fmt.Errorf("duplicate variable name detected: %s", name) + } + namesMap[name] = struct{}{} + } + + // List existing variables + existingVariables, err := listEnvironmentVariables(client, ctx, owner, repo, envName) + if err != nil { + return err + } + + // Sync variables (create, update, delete as needed) + err = syncEnvironmentVariables(ctx, client, owner, repo, envName, variables, existingVariables) + if err != nil { + return err + } + + d.SetId(buildTwoPartID(repo, envName)) + return resourceGithubActionsEnvironmentVariablesRead(d, meta) +} + +func resourceGithubActionsEnvironmentVariablesRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo, envName, err := parseTwoPartID(d.Id(), "repository", "environment") + if err != nil { + return err + } + + variables, err := listEnvironmentVariables(client, ctx, owner, repo, envName) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing environment variables %s from state because the environment or repository no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("repository", repo) + d.Set("environment", envName) + d.Set("variable", flattenEnvironmentVariables(variables)) + + return nil +} + +func resourceGithubActionsEnvironmentVariablesUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + envName := d.Get("environment").(string) + variables := d.Get("variable").(*schema.Set).List() + + // Check for 100 item environment variable limit + if len(variables) > 100 { + return fmt.Errorf("environment variable set cannot contain more than 100 items") + } + + // Check for any duplicate variable names + namesMap := make(map[string]struct{}) + for _, v := range variables { + variableConfig := v.(map[string]interface{}) + name := strings.ToUpper(variableConfig["name"].(string)) + if _, exists := namesMap[name]; exists { + return fmt.Errorf("duplicate variable name detected: %s", name) + } + namesMap[name] = struct{}{} + } + + // List existing variables + existingVariables, err := listEnvironmentVariables(client, ctx, owner, repo, envName) + if err != nil { + return err + } + + // Sync variables (create, update, delete as needed) + err = syncEnvironmentVariables(ctx, client, owner, repo, envName, variables, existingVariables) + if err != nil { + return err + } + + return resourceGithubActionsEnvironmentVariablesRead(d, meta) +} + +func resourceGithubActionsEnvironmentVariablesDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo, envName, err := parseTwoPartID(d.Id(), "repository", "environment") + if err != nil { + return err + } + + escapedEnvName := url.PathEscape(envName) + + // List all variables + variables, err := listEnvironmentVariables(client, ctx, owner, repo, envName) + if err != nil { + return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "environment variables (%s/%s)", repo, envName) + } + + // Delete each variable + for _, variable := range variables { + _, err = client.Actions.DeleteEnvVariable(ctx, owner, repo, escapedEnvName, variable.name) + if err != nil { + return fmt.Errorf("error deleting environment variable %s: %v", variable.name, err) + } + log.Printf("[DEBUG] Deleted environment variable: %s", variable.name) + } + + return nil +} diff --git a/github/resource_github_actions_environment_variables_test.go b/github/resource_github_actions_environment_variables_test.go new file mode 100644 index 0000000000..d63e209b29 --- /dev/null +++ b/github/resource_github_actions_environment_variables_test.go @@ -0,0 +1,288 @@ +package github + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubActionsEnvironmentVariables(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates and updates multiple environment variables without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "environment / test" + } + + resource "github_actions_environment_variables" "test_vars" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + + variable { + name = "test_variable_1" + value = "value_1" + } + + variable { + name = "test_variable_2" + value = "value_2" + } + } + `, randomID) + + updatedConfig := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "environment / test" + } + + resource "github_actions_environment_variables" "test_vars" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + + variable { + name = "test_variable_1" + value = "updated_value_1" + } + + variable { + name = "test_variable_3" + value = "value_3" + } + } + `, randomID) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_actions_environment_variables.test_vars", "variable.#", "2", + ), + resource.TestCheckResourceAttrSet( + "github_actions_environment_variables.test_vars", "variable.0.created_at", + ), + resource.TestCheckResourceAttrSet( + "github_actions_environment_variables.test_vars", "variable.0.updated_at", + ), + ), + "after": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_actions_environment_variables.test_vars", "variable.#", "2", + ), + resource.TestCheckResourceAttrSet( + "github_actions_environment_variables.test_vars", "variable.0.created_at", + ), + resource.TestCheckResourceAttrSet( + "github_actions_environment_variables.test_vars", "variable.0.updated_at", + ), + ), + } + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: updatedConfig, + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes all environment variables without error", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "environment / test" + } + + resource "github_actions_environment_variables" "test_vars" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + + variable { + name = "test_variable_1" + value = "value_1" + } + + variable { + name = "test_variable_2" + value = "value_2" + } + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Destroy: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports environment variables collection without error", func(t *testing.T) { + envName := "environment / test" + + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "%s" + } + + resource "github_actions_environment_variables" "test_vars" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + + variable { + name = "test_variable_1" + value = "value_1" + } + + variable { + name = "test_variable_2" + value = "value_2" + } + } + `, randomID, envName) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + }, + { + ResourceName: "github_actions_environment_variables.test_vars", + ImportStateId: fmt.Sprintf(`tf-acc-test-%s:%s`, randomID, envName), + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("enforces 100 variable limit", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_repository_environment" "test" { + repository = github_repository.test.name + environment = "environment / test" + } + + resource "github_actions_environment_variables" "test_vars" { + repository = github_repository.test.name + environment = github_repository_environment.test.environment + + dynamic "variable" { + for_each = range(101) + content { + name = "TEST_VAR_${variable.value + 1}" + value = "test-value-${variable.value + 1}" + } + } + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`environment variable set cannot contain more than 100 items`), + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + t.Skip("anonymous account not supported for this operation") + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/github/resource_github_actions_variables.go b/github/resource_github_actions_variables.go new file mode 100644 index 0000000000..8c2be765e4 --- /dev/null +++ b/github/resource_github_actions_variables.go @@ -0,0 +1,332 @@ +package github + +import ( + "context" + "fmt" + "log" + "net/http" + "sort" + "strings" + + "github.com/google/go-github/v66/github" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func resourceGithubActionsVariables() *schema.Resource { + return &schema.Resource{ + Create: resourceGithubActionsVariablesCreate, + Read: resourceGithubActionsVariablesRead, + Update: resourceGithubActionsVariablesUpdate, + Delete: resourceGithubActionsVariablesDelete, + Importer: &schema.ResourceImporter{ + StateContext: schema.ImportStatePassthroughContext, + }, + + Schema: map[string]*schema.Schema{ + "repository": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "Name of the repository.", + }, + "variable": { + Type: schema.TypeSet, + Optional: true, + Description: "List of variables to manage.", + Set: func(v interface{}) int { + m := v.(map[string]interface{}) + return schema.HashString(strings.ToUpper(m["name"].(string))) + }, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + Description: "Name of the variable.", + ValidateDiagFunc: validateSecretNameFunc, + DiffSuppressFunc: caseInsensitive(), + }, + "value": { + Type: schema.TypeString, + Required: true, + Description: "Value of the variable.", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of variable creation.", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date of variable update.", + }, + }, + }, + }, + }, + } +} + +type repoVariable struct { + name string + value string + createdAt string + updatedAt string +} + +func (v repoVariable) Empty() bool { + return v == repoVariable{} +} + +func flattenRepoVariable(variable repoVariable) map[string]interface{} { + if variable.Empty() { + return nil + } + + return map[string]interface{}{ + "name": variable.name, + "value": variable.value, + "created_at": variable.createdAt, + "updated_at": variable.updatedAt, + } +} + +func flattenRepoVariables(variables []repoVariable) []interface{} { + if variables == nil { + return nil + } + + // Sort variables by name for consistent ordering + sort.SliceStable(variables, func(i, j int) bool { + return variables[i].name < variables[j].name + }) + + result := make([]interface{}, len(variables)) + for i, variable := range variables { + result[i] = flattenRepoVariable(variable) + } + + return result +} + +// List all repository variables +func listRepoVariables(client *github.Client, ctx context.Context, owner, repo string) ([]repoVariable, error) { + options := github.ListOptions{ + PerPage: 100, + } + + var allVariables []repoVariable + for { + variables, resp, err := client.Actions.ListRepoVariables(ctx, owner, repo, &options) + if err != nil { + return nil, err + } + + for _, variable := range variables.Variables { + allVariables = append(allVariables, repoVariable{ + name: variable.Name, + value: variable.Value, + createdAt: variable.CreatedAt.String(), + updatedAt: variable.UpdatedAt.String(), + }) + } + + if resp.NextPage == 0 { + break + } + options.Page = resp.NextPage + } + + return allVariables, nil +} + +// Create or update variables to match desired state +func syncRepoVariables(ctx context.Context, client *github.Client, owner, repo string, + wantVariables []interface{}, existingVariables []repoVariable) error { + + // Map of existing variables by name for easy lookup + existingMap := make(map[string]repoVariable) + for _, v := range existingVariables { + existingMap[v.name] = v + } + + // Track variables to create, update, or delete + for _, v := range wantVariables { + varConfig := v.(map[string]interface{}) + name := varConfig["name"].(string) + value := varConfig["value"].(string) + + if existing, exists := existingMap[name]; exists { + // Variable exists, check if value has changed + if existing.value != value { + // Update variable + variable := &github.ActionsVariable{ + Name: name, + Value: value, + } + + _, err := client.Actions.UpdateRepoVariable(ctx, owner, repo, variable) + if err != nil { + return fmt.Errorf("error updating repository variable %s: %v", name, err) + } + log.Printf("[DEBUG] Updated repository variable: %s", name) + } + + // Remove from map to track what variables to keep + delete(existingMap, name) + } else { + // Create new variable + variable := &github.ActionsVariable{ + Name: name, + Value: value, + } + + _, err := client.Actions.CreateRepoVariable(ctx, owner, repo, variable) + if err != nil { + return fmt.Errorf("error creating repository variable %s: %v", name, err) + } + log.Printf("[DEBUG] Created repository variable: %s", name) + } + } + + // Delete variables that are no longer in config + for name := range existingMap { + _, err := client.Actions.DeleteRepoVariable(ctx, owner, repo, name) + if err != nil { + return fmt.Errorf("error deleting repository variable %s: %v", name, err) + } + log.Printf("[DEBUG] Deleted repository variable: %s", name) + } + + return nil +} + +func resourceGithubActionsVariablesCreate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + variables := d.Get("variable").(*schema.Set).List() + + // Check for 500 item variable limit + if len(variables) > 500 { + return fmt.Errorf("variable set cannot contain more than 500 items") + } + + // Check for any duplicate variable names + namesMap := make(map[string]struct{}) + for _, v := range variables { + variableConfig := v.(map[string]interface{}) + name := strings.ToUpper(variableConfig["name"].(string)) + if _, exists := namesMap[name]; exists { + return fmt.Errorf("duplicate variable name detected: %s", name) + } + namesMap[name] = struct{}{} + } + + // List existing variables + existingVariables, err := listRepoVariables(client, ctx, owner, repo) + if err != nil { + return err + } + + // Sync variables (create, update, delete as needed) + err = syncRepoVariables(ctx, client, owner, repo, variables, existingVariables) + if err != nil { + return err + } + + d.SetId(repo) + return resourceGithubActionsVariablesRead(d, meta) +} + +func resourceGithubActionsVariablesRead(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Id() + + variables, err := listRepoVariables(client, ctx, owner, repo) + if err != nil { + if ghErr, ok := err.(*github.ErrorResponse); ok { + if ghErr.Response.StatusCode == http.StatusNotFound { + log.Printf("[INFO] Removing repository variables %s from state because the repository no longer exists in GitHub", d.Id()) + d.SetId("") + return nil + } + } + return err + } + + d.Set("repository", repo) + d.Set("variable", flattenRepoVariables(variables)) + + return nil +} + +func resourceGithubActionsVariablesUpdate(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + variables := d.Get("variable").(*schema.Set).List() + + // Check for 500 item variable limit + if len(variables) > 500 { + return fmt.Errorf("variable set cannot contain more than 500 items") + } + + // Check for any duplicate variable names + namesMap := make(map[string]struct{}) + for _, v := range variables { + variableConfig := v.(map[string]interface{}) + name := strings.ToUpper(variableConfig["name"].(string)) + if _, exists := namesMap[name]; exists { + return fmt.Errorf("duplicate variable name detected: %s", name) + } + namesMap[name] = struct{}{} + } + + // List existing variables + existingVariables, err := listRepoVariables(client, ctx, owner, repo) + if err != nil { + return err + } + + // Sync variables (create, update, delete as needed) + err = syncRepoVariables(ctx, client, owner, repo, variables, existingVariables) + if err != nil { + return err + } + + return resourceGithubActionsVariablesRead(d, meta) +} + +func resourceGithubActionsVariablesDelete(d *schema.ResourceData, meta interface{}) error { + client := meta.(*Owner).v3client + owner := meta.(*Owner).name + ctx := context.Background() + + repo := d.Get("repository").(string) + + // List all variables + variables, err := listRepoVariables(client, ctx, owner, repo) + if err != nil { + return deleteResourceOn404AndSwallow304OtherwiseReturnError(err, d, "repository variables (%s)", repo) + } + + // Delete each variable + for _, variable := range variables { + _, err = client.Actions.DeleteRepoVariable(ctx, owner, repo, variable.name) + if err != nil { + return fmt.Errorf("error deleting repository variable %s: %v", variable.name, err) + } + log.Printf("[DEBUG] Deleted repository variable: %s", variable.name) + } + + return nil +} diff --git a/github/resource_github_actions_variables_test.go b/github/resource_github_actions_variables_test.go new file mode 100644 index 0000000000..3a6135a078 --- /dev/null +++ b/github/resource_github_actions_variables_test.go @@ -0,0 +1,271 @@ +package github + +import ( + "fmt" + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubActionsVariables(t *testing.T) { + + randomID := acctest.RandStringFromCharSet(5, acctest.CharSetAlphaNum) + + t.Run("creates and updates repository variables", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + variable { + name = "test_variable_1" + value = "test_value_1" + } + variable { + name = "test_variable_2" + value = "test_value_2" + } + } + `, randomID) + + checks := map[string]resource.TestCheckFunc{ + "before": resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_actions_variables.test", "variable.#", "2", + ), + resource.TestMatchResourceAttr( + "github_actions_variables.test", "variable.0.created_at", regexp.MustCompile(`\d`), + ), + resource.TestMatchResourceAttr( + "github_actions_variables.test", "variable.0.updated_at", regexp.MustCompile(`\d`), + ), + ), + } + + updateConfig := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + variable { + name = "test_variable_1" + value = "updated_value_1" + } + variable { + name = "test_variable_3" + value = "test_value_3" + } + } + `, randomID) + + checks["after"] = resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "github_actions_variables.test", "variable.#", "2", + ), + resource.TestMatchResourceAttr( + "github_actions_variables.test", "variable.0.updated_at", regexp.MustCompile(`\d`), + ), + ) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: checks["before"], + }, + { + Config: updateConfig, + Check: checks["after"], + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + testCase(t, anonymous) + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("deletes all variables", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + variable { + name = "test_variable_1" + value = "test_value_1" + } + variable { + name = "test_variable_2" + value = "test_value_2" + } + } + `, randomID) + + emptyConfig := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.TestCheckResourceAttr( + "github_actions_variables.test", "variable.#", "2", + ), + }, + { + Config: emptyConfig, + Check: resource.TestCheckResourceAttr( + "github_actions_variables.test", "variable.#", "0", + ), + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + testCase(t, anonymous) + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("imports variables collection", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + variable { + name = "test_variable_1" + value = "test_value_1" + } + variable { + name = "test_variable_2" + value = "test_value_2" + } + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.TestCheckResourceAttr( + "github_actions_variables.test", "variable.#", "2", + ), + }, + { + ResourceName: "github_actions_variables.test", + ImportState: true, + ImportStateVerify: true, + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + testCase(t, anonymous) + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) + + t.Run("enforces 500 variable limit", func(t *testing.T) { + config := fmt.Sprintf(` + resource "github_repository" "test" { + name = "tf-acc-test-%s" + auto_init = true + } + + resource "github_actions_variables" "test" { + repository = github_repository.test.name + + dynamic "variable" { + for_each = range(501) + content { + name = "TEST_VAR_${variable.value + 1}" + value = "test-value-${variable.value + 1}" + } + } + } + `, randomID) + + testCase := func(t *testing.T, mode string) { + resource.Test(t, resource.TestCase{ + PreCheck: func() { skipUnlessMode(t, mode) }, + Providers: testAccProviders, + Steps: []resource.TestStep{ + { + Config: config, + ExpectError: regexp.MustCompile(`variable set cannot contain more than 500 items`), + }, + }, + }) + } + + t.Run("with an anonymous account", func(t *testing.T) { + testCase(t, anonymous) + }) + + t.Run("with an individual account", func(t *testing.T) { + testCase(t, individual) + }) + + t.Run("with an organization account", func(t *testing.T) { + testCase(t, organization) + }) + }) +} diff --git a/website/docs/r/actions_environment_variable.html.markdown b/website/docs/r/actions_environment_variable.html.markdown index 32079c86b1..1d558d6ea5 100644 --- a/website/docs/r/actions_environment_variable.html.markdown +++ b/website/docs/r/actions_environment_variable.html.markdown @@ -10,6 +10,9 @@ description: |- This resource allows you to create and manage GitHub Actions variables within your GitHub repository environments. You must have write access to a repository to use this resource. +~> Note: github_actions_environment_variable cannot be used in conjunction with github_actions_environment_variables or +they will fight over what your policy should be. For managing multiple variables in a single resource, use github_actions_environment_variables instead. + ## Example Usage ```hcl diff --git a/website/docs/r/actions_environment_variables.html.markdown b/website/docs/r/actions_environment_variables.html.markdown new file mode 100644 index 0000000000..278d495d68 --- /dev/null +++ b/website/docs/r/actions_environment_variables.html.markdown @@ -0,0 +1,66 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_environment_variables" +description: |- + Creates and manages multiple Action variables within a GitHub repository environment +--- + +# github_actions_environment_variables + +This resource allows you to create and manage multiple GitHub Actions variables within your GitHub repository environments. +You must have write access to a repository to use this resource. + +~> Note: github_actions_environment_variables cannot be used in conjunction with github_actions_environment_variable or +they will fight over what your policy should be. + +## Example Usage + +```hcl +data "github_repository" "repo" { + full_name = "my-org/repo" +} + +resource "github_repository_environment" "repo_environment" { + repository = data.github_repository.repo.name + environment = "example_environment" +} + +resource "github_actions_environment_variables" "environment_vars" { + repository = data.github_repository.repo.name + environment = github_repository_environment.repo_environment.environment + + variable { + name = "first_variable" + value = "first_value" + } + + variable { + name = "second_variable" + value = "second_value" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) Name of the repository. +* `environment` - (Required) Name of the environment. +* `variable` - (Optional) Set of variables to manage. Limited to a maximum of 100 variables per environment. Each variable block supports the following: + * `name` - (Required) Name of the variable. + * `value` - (Required) Value of the variable. + +## Attributes Reference + +In addition to the arguments above, each variable block exports the following read-only attributes: + +* `created_at` - Date of variable creation. +* `updated_at` - Date of variable update. + +## Import + +This resource can be imported using an ID made up of the repository and environment name: + +``` +$ terraform import github_actions_environment_variables.vars repository:environment diff --git a/website/docs/r/actions_variable.html.markdown b/website/docs/r/actions_variable.html.markdown index d137d58bd6..ed37e6e57b 100644 --- a/website/docs/r/actions_variable.html.markdown +++ b/website/docs/r/actions_variable.html.markdown @@ -10,6 +10,9 @@ description: |- This resource allows you to create and manage GitHub Actions variables within your GitHub repositories. You must have write access to a repository to use this resource. +~> Note: github_actions_variable cannot be used in conjunction with github_actions_variables or +they will fight over what your policy should be. For managing multiple variables in a single resource, use github_actions_variables instead. + ## Example Usage diff --git a/website/docs/r/actions_variables.html.markdown b/website/docs/r/actions_variables.html.markdown new file mode 100644 index 0000000000..a49b827fb2 --- /dev/null +++ b/website/docs/r/actions_variables.html.markdown @@ -0,0 +1,55 @@ +--- +layout: "github" +page_title: "GitHub: github_actions_variables" +description: |- + Creates and manages multiple Action variables within a GitHub repository +--- + +# github_actions_variables + +This resource allows you to create and manage multiple GitHub Actions variables within your GitHub repositories. +You must have write access to a repository to use this resource. + +~> Note: github_actions_variables cannot be used in conjunction with github_actions_variable or +they will fight over what your policy should be. + +## Example Usage + +```hcl +resource "github_actions_variables" "repo_vars" { + repository = "example_repository" + + variable { + name = "first_variable" + value = "first_value" + } + + variable { + name = "second_variable" + value = "second_value" + } +} +``` + +## Argument Reference + +The following arguments are supported: + +* `repository` - (Required) Name of the repository. +* `variable` - (Optional) Set of variables to manage. Limited to a maximum of 500 variables per repository. Each variable block supports the following: + * `name` - (Required) Name of the variable. + * `value` - (Required) Value of the variable. + +## Attributes Reference + +In addition to the arguments above, each variable block exports the following read-only attributes: + +* `created_at` - Date of variable creation. +* `updated_at` - Date of variable update. + +## Import + +This resource can be imported using the repository name: + +``` +$ terraform import github_actions_variables.vars repository