diff --git a/github/acc_test.go b/github/acc_test.go index c267467476..7f0e3dce94 100644 --- a/github/acc_test.go +++ b/github/acc_test.go @@ -49,6 +49,9 @@ type testAccConfig struct { testPublicRepository string testPublicRepositoryOwner string testPublicReleaseId int + testPublicRelaseAssetId string + testPublicRelaseAssetName string + testPublicReleaseAssetContent string testPublicTemplateRepository string testPublicTemplateRepositoryOwner string testGHActionsAppInstallationId int @@ -105,11 +108,16 @@ func TestMain(m *testing.M) { } config := testAccConfig{ - baseURL: baseURL, - authMode: authMode, - testPublicRepository: "terraform-provider-github", - testPublicRepositoryOwner: "integrations", - testPublicReleaseId: 186531906, + baseURL: baseURL, + authMode: authMode, + testPublicRepository: "terraform-provider-github", + testPublicRepositoryOwner: "integrations", + testPublicReleaseId: 186531906, + // The terraform-provider-github_6.4.0_manifest.json asset ID from + // https://github.com/integrations/terraform-provider-github/releases/tag/v6.4.0 + testPublicRelaseAssetId: "207956097", + testPublicRelaseAssetName: "terraform-provider-github_6.4.0_manifest.json", + testPublicReleaseAssetContent: "{\n \"version\": 1,\n \"metadata\": {\n \"protocol_versions\": [\n \"5.0\"\n ]\n }\n}", testPublicTemplateRepository: "template-repository", testPublicTemplateRepositoryOwner: "template-repository", testGHActionsAppInstallationId: 15368, diff --git a/github/config.go b/github/config.go index 869677fc42..a61dd541b7 100644 --- a/github/config.go +++ b/github/config.go @@ -189,7 +189,11 @@ func (injector *previewHeaderInjectorTransport) RoundTrip(req *http.Request) (*h header := req.Header.Get(name) if header == "" { header = value - } else { + // NOTE: Some API endpoints expect a single Accept: application/octet-stream header. + // If one has been set, it's necessary to preserve it as-is, without + // appending previewHeaders value. + // See https://github.com/google/go-github/pull/3392 + } else if strings.ToLower(name) != "accept" || header != "application/octet-stream" { header = strings.Join([]string{header, value}, ",") } req.Header.Set(name, header) diff --git a/github/config_test.go b/github/config_test.go index 67d6223031..1d88e231bc 100644 --- a/github/config_test.go +++ b/github/config_test.go @@ -2,6 +2,8 @@ package github import ( "context" + "net/http" + "net/http/httptest" "testing" "github.com/shurcooL/githubv4" @@ -300,3 +302,215 @@ func TestAccConfigMeta(t *testing.T) { } }) } + +func TestPreviewHeaderInjectorTransport_RoundTrip(t *testing.T) { + tests := []struct { + name string + previewHeaders map[string]string + existingHeaders map[string]string + expectedHeaders map[string]string + expectRoundTripCall bool + }{ + { + name: "empty preview headers", + previewHeaders: map[string]string{}, + existingHeaders: map[string]string{"User-Agent": "test"}, + expectedHeaders: map[string]string{"User-Agent": "test"}, + expectRoundTripCall: true, + }, + { + name: "add new preview header", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + }, + existingHeaders: map[string]string{}, + expectedHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + }, + expectRoundTripCall: true, + }, + { + name: "append to existing header", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.preview+json", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing Accept application/octet-stream", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing accept application/octet-stream (lowercase)", + previewHeaders: map[string]string{ + "accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "preserve existing Accept application/octet-stream (mixed case)", + previewHeaders: map[string]string{ + "AcCePt": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "Accept": "application/octet-stream", + }, + expectRoundTripCall: true, + }, + { + name: "multiple preview headers", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + existingHeaders: map[string]string{}, + expectedHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-Github-Api-Version": "2022-11-28", + }, + expectRoundTripCall: true, + }, + { + name: "append multiple preview headers to existing", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.v3+json", + "X-GitHub-Api-Version": "2022-11-28", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + "X-GitHub-Api-Version": "2021-01-01", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.v3+json", + "X-Github-Api-Version": "2021-01-01,2022-11-28", + }, + expectRoundTripCall: true, + }, + { + name: "non-accept headers always append", + previewHeaders: map[string]string{ + "X-Custom-Header": "preview-value", + }, + existingHeaders: map[string]string{ + "X-Custom-Header": "application/octet-stream", + }, + expectedHeaders: map[string]string{ + "X-Custom-Header": "application/octet-stream,preview-value", + }, + expectRoundTripCall: true, + }, + { + name: "accept header with different value appends", + previewHeaders: map[string]string{ + "Accept": "application/vnd.github.preview+json", + }, + existingHeaders: map[string]string{ + "Accept": "application/json", + }, + expectedHeaders: map[string]string{ + "Accept": "application/json,application/vnd.github.preview+json", + }, + expectRoundTripCall: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock RoundTripper that records the request + var capturedRequest *http.Request + mockRT := &mockRoundTripper{ + roundTripFunc: func(req *http.Request) (*http.Response, error) { + capturedRequest = req + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + }, nil + }, + } + + injector := &previewHeaderInjectorTransport{ + rt: mockRT, + previewHeaders: tt.previewHeaders, + } + + // Create a test request with existing headers + req := httptest.NewRequest(http.MethodGet, "https://api.github.com/test", nil) + for name, value := range tt.existingHeaders { + req.Header.Set(name, value) + } + + // Execute RoundTrip + resp, err := injector.RoundTrip(req) + // Verify no error + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify response + if resp == nil { + t.Fatal("expected non-nil response") + } + + // Verify RoundTrip was called on the underlying transport + if tt.expectRoundTripCall && capturedRequest == nil { + t.Fatal("expected RoundTrip to be called on underlying transport") + } + + // Verify headers in the captured request + if capturedRequest != nil { + for name, expectedValue := range tt.expectedHeaders { + actualValue := capturedRequest.Header.Get(name) + if actualValue != expectedValue { + t.Errorf("header %q: expected %q, got %q", name, expectedValue, actualValue) + } + } + + // Verify no unexpected headers were added + for name := range capturedRequest.Header { + if _, exists := tt.expectedHeaders[name]; !exists { + // Allow headers that were in existingHeaders but not in expectedHeaders + if _, wasExisting := tt.existingHeaders[name]; !wasExisting { + t.Errorf("unexpected header %q: %q", name, capturedRequest.Header.Get(name)) + } + } + } + } + }) + } +} + +// mockRoundTripper is a mock implementation of http.RoundTripper for testing. +type mockRoundTripper struct { + roundTripFunc func(*http.Request) (*http.Response, error) +} + +func (m *mockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if m.roundTripFunc != nil { + return m.roundTripFunc(req) + } + return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}, nil +} diff --git a/github/data_source_github_release_asset.go b/github/data_source_github_release_asset.go new file mode 100644 index 0000000000..29a5825258 --- /dev/null +++ b/github/data_source_github_release_asset.go @@ -0,0 +1,185 @@ +package github + +import ( + "context" + "encoding/base64" + "io" + "net/http" + "strconv" + "strings" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func dataSourceGithubReleaseAsset() *schema.Resource { + return &schema.Resource{ + Description: "Retrieve information about a GitHub release asset.", + ReadContext: dataSourceGithubReleaseAssetRead, + + Schema: map[string]*schema.Schema{ + "asset_id": { + Type: schema.TypeInt, + Required: true, + Description: "ID of the release asset to retrieve", + }, + "owner": { + Type: schema.TypeString, + Required: true, + Description: "Owner of the repository", + }, + "repository": { + Type: schema.TypeString, + Required: true, + Description: "Name of the repository to retrieve the release asset from", + }, + "download_file_contents": { + Type: schema.TypeBool, + Optional: true, + Default: false, + Description: "Whether to download the asset file content into the `file_contents` attribute", + }, + "file_contents": { + Type: schema.TypeString, + Computed: true, + Description: "The base64-encoded release asset file contents (requires `download_file_contents` to be `true`)", + }, + "url": { + Type: schema.TypeString, + Computed: true, + Description: "URL of the asset", + }, + "node_id": { + Type: schema.TypeString, + Computed: true, + Description: "Node ID of the asset", + }, + "name": { + Type: schema.TypeString, + Computed: true, + Description: "File name of the asset", + }, + "label": { + Type: schema.TypeString, + Computed: true, + Description: "Label for the asset", + }, + "content_type": { + Type: schema.TypeString, + Computed: true, + Description: "MIME type of the asset", + }, + "size": { + Type: schema.TypeInt, + Computed: true, + Description: "Asset size in bytes", + }, + "created_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date the asset was created", + }, + "updated_at": { + Type: schema.TypeString, + Computed: true, + Description: "Date the asset was updated", + }, + "browser_download_url": { + Type: schema.TypeString, + Computed: true, + Description: "Browser URL from which the release asset can be downloaded", + }, + }, + } +} + +func dataSourceGithubReleaseAssetRead(ctx context.Context, d *schema.ResourceData, meta any) diag.Diagnostics { + repository := d.Get("repository").(string) + owner := d.Get("owner").(string) + + client := meta.(*Owner).v3client + + assetID := int64(d.Get("asset_id").(int)) + asset, _, err := client.Repositories.GetReleaseAsset(ctx, owner, repository, assetID) + if err != nil { + return diag.FromErr(err) + } + + d.SetId(buildThreePartID(owner, repository, strconv.FormatInt(assetID, 10))) + + if err := d.Set("url", asset.URL); err != nil { + return diag.FromErr(err) + } + if err := d.Set("node_id", asset.NodeID); err != nil { + return diag.FromErr(err) + } + if err := d.Set("name", asset.Name); err != nil { + return diag.FromErr(err) + } + if err := d.Set("label", asset.Label); err != nil { + return diag.FromErr(err) + } + if err := d.Set("content_type", asset.ContentType); err != nil { + return diag.FromErr(err) + } + if err := d.Set("size", asset.Size); err != nil { + return diag.FromErr(err) + } + if err := d.Set("created_at", asset.CreatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("updated_at", asset.UpdatedAt.String()); err != nil { + return diag.FromErr(err) + } + if err := d.Set("browser_download_url", asset.BrowserDownloadURL); err != nil { + return diag.FromErr(err) + } + + if !d.Get("download_file_contents").(bool) { + return nil + } + + // Use a client copy to avoid possible mutation of shared GitHub client state + // by client.Repositories.DownloadReleaseAsset. + clientCopy := client.Client() + req, err := http.NewRequestWithContext(ctx, "GET", asset.GetBrowserDownloadURL(), nil) + if err != nil { + return diag.FromErr(err) + } + + req.Header.Set("Accept", "application/octet-stream") + resp, err := clientCopy.Do(req) + if err != nil { + return diag.FromErr(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return diag.Errorf("failed to get release asset (%s/%s %d): %s", owner, repository, assetID, resp.Status) + } + + buf := new(strings.Builder) + encoder := base64.NewEncoder(base64.StdEncoding, buf) + defer encoder.Close() + buffer := make([]byte, 4096) + for { + n, err := resp.Body.Read(buffer) + if err != nil && err != io.EOF { + return diag.FromErr(err) + } + if n > 0 { + if _, err := encoder.Write(buffer[:n]); err != nil { + return diag.FromErr(err) + } + } + if err == io.EOF { + break + } + } + + if err := d.Set("file_contents", buf.String()); err != nil { + return diag.FromErr(err) + } + + return nil +} diff --git a/github/data_source_github_release_asset_test.go b/github/data_source_github_release_asset_test.go new file mode 100644 index 0000000000..134ee87668 --- /dev/null +++ b/github/data_source_github_release_asset_test.go @@ -0,0 +1,79 @@ +package github + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" +) + +func TestAccGithubReleaseAssetDataSource(t *testing.T) { + testRepositoryOwner := testAccConf.testPublicRepositoryOwner + testReleaseRepository := testAccConf.testPublicRepository + testReleaseAssetID := testAccConf.testPublicRelaseAssetId + testReleaseAssetName := testAccConf.testPublicRelaseAssetName + testReleaseAssetContent := testAccConf.testPublicReleaseAssetContent + + t.Run("queries and downloads specified asset ID", func(t *testing.T) { + config := fmt.Sprintf(` + data "github_release_asset" "test" { + repository = "%s" + owner = "%s" + asset_id = "%s" + download_file_contents = true + } + + output "github_release_asset_contents" { + value = base64decode(data.github_release_asset.test.file_contents) + } + `, testReleaseRepository, testRepositoryOwner, testReleaseAssetID) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "asset_id", testReleaseAssetID, + ), + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "name", testReleaseAssetName, + ), + resource.TestCheckOutput("github_release_asset_contents", testReleaseAssetContent), + ), + }, + }, + }) + }) + + t.Run("queries without downloading the specified asset ID", func(t *testing.T) { + config := fmt.Sprintf(` + data "github_release_asset" "test" { + repository = "%s" + owner = "%s" + asset_id = "%s" + } + `, testReleaseRepository, testRepositoryOwner, testReleaseAssetID) + + resource.Test(t, resource.TestCase{ + ProviderFactories: providerFactories, + Steps: []resource.TestStep{ + { + Config: config, + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "asset_id", testReleaseAssetID, + ), + resource.TestCheckResourceAttr( + "data.github_release_asset.test", "name", testReleaseAssetName, + ), + resource.TestCheckNoResourceAttr( + "data.github_release_asset.test", "file", + ), + ), + }, + }, + }) + }) +} diff --git a/github/provider.go b/github/provider.go index 4f857d27c0..9e9a058892 100644 --- a/github/provider.go +++ b/github/provider.go @@ -267,6 +267,7 @@ func Provider() *schema.Provider { "github_organization_webhooks": dataSourceGithubOrganizationWebhooks(), "github_ref": dataSourceGithubRef(), "github_release": dataSourceGithubRelease(), + "github_release_asset": dataSourceGithubReleaseAsset(), "github_repositories": dataSourceGithubRepositories(), "github_repository": dataSourceGithubRepository(), "github_repository_autolink_references": dataSourceGithubRepositoryAutolinkReferences(), diff --git a/website/docs/d/release_asset.html.markdown b/website/docs/d/release_asset.html.markdown new file mode 100644 index 0000000000..a5b9faa1ec --- /dev/null +++ b/website/docs/d/release_asset.html.markdown @@ -0,0 +1,88 @@ +--- +layout: "github" +page_title: "GitHub: github_release_asset" +description: |- + Get information on a GitHub release asset. +--- + +# github\_release\_asset + +Use this data source to retrieve information about a GitHub release asset. + +## Example Usage +To retrieve a specific release asset from a repository based on its ID: + +```hcl +data "github_release_asset" "example" { + repository = "example-repository" + owner = "example-owner" + asset_id = 12345 +} +``` + +To retrieve a specific release asset from a repository, and download the file +into a `file` attribute on the data source: + +```hcl +data "github_release_asset" "example" { + repository = "example-repository" + owner = "example-owner" + asset_id = 12345 + download_file = true +} +``` + + +To retrieve the first release asset associated with the latest release in a repository: + +```hcl +data "github_release" "example" { + repository = "example-repository" + owner = "example-owner" + retrieve_by = "latest" +} + +data "github_release_asset" "example" { + repository = "example-repository" + owner = "example-owner" + asset_id = data.github_release.example.assets[0].id +} +``` + +To retrieve all release assets associated with the the latest release in a repository: + +```hcl +data "github_release" "example" { + repository = "example-repository" + owner = "example-owner" + retrieve_by = "latest" +} + +data "github_release_asset" "example" { + count = length(data.github_release.example.assets) + repository = "example-repository" + owner = "example-owner" + asset_id = data.github_release.example.assets[count.index].id +} +``` + +## Argument Reference + +* `repository` - (Required) Name of the repository to retrieve the release from +* `owner` - (Required) Owner of the repository +* `asset_id` - (Required) ID of the release asset to retrieve +* `download_file_contents` - (Optional) Whether to download the asset file content into the `file_contents` attribute (defaults to `false`) + +## Attributes Reference + +* `id` - ID of the asset +* `url` - URL of the asset +* `node_id` - Node ID of the asset +* `name` - The file name of the asset +* `label` - Label for the asset +* `content_type` - MIME type of the asset +* `size` - Asset size in bytes +* `created_at` - Date the asset was created +* `updated_at` - Date the asset was last updated +* `browser_download_url` - Browser URL from which the release asset can be downloaded +* `file_contents` - The base64-encoded release asset file contents (requires `download_file_contents` to be `true`)