Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,35 @@ on:
jobs:
lint-tidy:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
steps:
# Enforce TPT-1234: prefix on PR titles, with the following exemptions:
# - PRs labeled 'dependencies' (e.g. Dependabot PRs)
# - PRs labeled 'hotfix' (urgent fixes that may not have a ticket)
# - PRs labeled 'community-contribution' (external contributors without TPT tickets)
# - PRs labeled 'ignore-for-release' (release PRs that don't need a ticket prefix)
- name: Validate PR Title
if: github.event_name == 'pull_request'
uses: amannn/action-semantic-pull-request@v6
with:
types: |
TPT-\d+
requireScope: false
# Override the default header pattern to allow hyphens and digits in the type
# (e.g. "TPT-4298: Description"). The default pattern only matches word
# characters (\w) which excludes hyphens.
headerPattern: '^([\w-]+):\s?(.*)$'
headerPatternCorrespondence: type, subject
ignoreLabels: |
dependencies
hotfix
community-contribution
ignore-for-release
env:
GITHUB_TOKEN: ${{ github.token }}

- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
Expand Down
37 changes: 37 additions & 0 deletions .github/workflows/clean-release-notes.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Clean Release Notes

on:
release:
types: [published]

jobs:
clean-release-notes:
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Remove ticket prefixes from release notes
uses: actions/github-script@v7
with:
script: |
const release = context.payload.release;

let body = release.body;

if (!body) {
console.log("Release body empty, nothing to clean.");
return;
}

// Remove ticket prefixes like "TPT-1234: " or "TPT-1234:"
body = body.replace(/TPT-\d+:\s*/g, '');

await github.rest.repos.updateRelease({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: release.id,
body: body
});

console.log("Release notes cleaned.");
39 changes: 35 additions & 4 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ Body: {{.Body}}`))

var envDebug = false

// redactHeadersMap is a map of headers that should be redacted in logs,
// mapping the header name to its redacted value.
var redactHeadersMap = map[string]string{
"Authorization": "Bearer *******************************",
}

// Client is a wrapper around the Resty client
type Client struct {
resty *resty.Client
Expand Down Expand Up @@ -394,6 +400,19 @@ func (c *httpClient) applyAfterResponse(resp *http.Response) error {
return nil
}

// nolint:unused
func redactHeaders(headers http.Header) http.Header {
redacted := headers.Clone()

for header, redactedValue := range redactHeadersMap {
if headers.Get(header) != "" {
redacted.Set(header, redactedValue)
}
}

return redacted
}

// nolint:unused
func (c *httpClient) logRequest(req *http.Request, method, url string, bodyBuffer *bytes.Buffer) {
var reqBody string
Expand All @@ -408,7 +427,7 @@ func (c *httpClient) logRequest(req *http.Request, method, url string, bodyBuffe
err := reqLogTemplate.Execute(&logBuf, map[string]any{
"Method": method,
"URL": url,
"Headers": req.Header,
"Headers": redactHeaders(req.Header),
"Body": reqBody,
})
if err == nil {
Expand Down Expand Up @@ -456,7 +475,7 @@ func (c *httpClient) logResponse(resp *http.Response) (*http.Response, error) {

err := respLogTemplate.Execute(&logBuf, map[string]any{
"Status": resp.Status,
"Headers": resp.Header,
"Headers": redactHeaders(resp.Header),
"Body": respBody.String(),
})
if err == nil {
Expand Down Expand Up @@ -827,10 +846,22 @@ func (c *Client) updateHostURL() {
)
}

func redactLogHeaders(header http.Header) {
for h, redactedValue := range redactHeadersMap {
if header.Get(h) != "" {
header.Set(h, redactedValue)
}
}
}

func (c *Client) enableLogSanitization() *Client {
c.resty.OnRequestLog(func(r *resty.RequestLog) error {
// masking authorization header
r.Header.Set("Authorization", "Bearer *******************************")
redactLogHeaders(r.Header)
return nil
})

c.resty.OnResponseLog(func(r *resty.ResponseLog) error {
redactLogHeaders(r.Header)
return nil
})

Expand Down
99 changes: 99 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/jarcoal/httpmock"
"github.com/linode/linodego/internal/testutil"
"github.com/stretchr/testify/require"
)

func TestClient_SetAPIVersion(t *testing.T) {
Expand Down Expand Up @@ -703,3 +704,101 @@ func TestMonitorClient_SetAPIBasics(t *testing.T) {
t.Fatal(cmp.Diff(client.resty.BaseURL, expectedHost))
}
}

func TestRedactHeaders(t *testing.T) {
tests := []struct {
name string
headers http.Header
wantVal map[string]string
}{
{
name: "redacts authorization header",
headers: http.Header{
"Authorization": []string{"Bearer supersecrettoken"},
"Content-Type": []string{"application/json"},
},
wantVal: map[string]string{
"Authorization": redactHeadersMap["Authorization"],
"Content-Type": "application/json",
},
},
{
name: "leaves non-sensitive headers unchanged",
headers: http.Header{
"Content-Type": []string{"application/json"},
"Accept": []string{"application/json"},
},
wantVal: map[string]string{
"Content-Type": "application/json",
"Accept": "application/json",
},
},
{
name: "handles empty headers",
headers: http.Header{},
wantVal: map[string]string{},
},
{
name: "does not mutate original headers",
headers: http.Header{
"Authorization": []string{"Bearer supersecrettoken"},
},
wantVal: map[string]string{
"Authorization": redactHeadersMap["Authorization"],
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalAuth := tt.headers.Get("Authorization")

result := redactHeaders(tt.headers)

// Verify expected values in result
for key, expectedVal := range tt.wantVal {
if got := result.Get(key); got != expectedVal {
t.Errorf("redactHeaders() header %q = %q, want %q", key, got, expectedVal)
}
}

// Verify original was not mutated
if tt.headers.Get("Authorization") != originalAuth {
t.Error("redactHeaders() mutated the original headers")
}
})
}
}

func TestEnableLogSanitization(t *testing.T) {
mockClient := testutil.CreateMockClient(t, NewClient)
mockClient.SetDebug(true)

plainTextToken := "supersecrettoken"
mockClient.SetToken(plainTextToken)

var logBuf bytes.Buffer
logger := testutil.CreateLogger()
logger.L.SetOutput(&logBuf)
mockClient.SetLogger(logger)

httpmock.RegisterResponder("GET", "=~.*",
httpmock.NewStringResponder(200, `{}`).HeaderSet(http.Header{
"Authorization": []string{"Bearer " + plainTextToken},
}))

_, err := mockClient.resty.R().Get("https://api.linode.com/v4/test")
require.NoError(t, err)

logOutput := logBuf.String()

// Verify token is not present in either request or response logs
if strings.Contains(logOutput, plainTextToken) {
t.Errorf("log output contains raw token %q, expected it to be redacted", plainTextToken)
}

// Verify Authorization header still appears (as redacted value) in request log
if !strings.Contains(logOutput, "Authorization") {
t.Error("expected Authorization header to appear in request log output")
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ require (
github.com/google/go-querystring v1.2.0
github.com/jarcoal/httpmock v1.4.1
golang.org/x/net v0.51.0
golang.org/x/oauth2 v0.35.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.34.0
gopkg.in/ini.v1 v1.67.1
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
Expand Down
1 change: 1 addition & 0 deletions instance_ips.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ type InstanceIP struct {
Region string `json:"region"`
VPCNAT1To1 *InstanceIPNAT1To1 `json:"vpc_nat_1_1"`
Reserved bool `json:"reserved"`
Tags []string `json:"tags"`
}

// VPCIP represents a private IP address in a VPC subnet with additional networking details
Expand Down
2 changes: 1 addition & 1 deletion k8s/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ require (
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/spf13/pflag v1.0.5 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
Expand Down
4 changes: 2 additions & 2 deletions k8s/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
Expand Down
43 changes: 42 additions & 1 deletion network_reserved_ips.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,35 @@ import (
// ReserveIPOptions represents the options for reserving an IP address
// NOTE: Reserved IP feature may not currently be available to all users.
type ReserveIPOptions struct {
Region string `json:"region"`
Region string `json:"region"`
Tags []string `json:"tags,omitempty"`
}

// UpdateReservedIPOptions represents the options for updating a reserved IP address
// NOTE: Reserved IP feature may not currently be available to all users.
type UpdateReservedIPOptions struct {
Tags []string `json:"tags"`
}

// ReservedIPPrice represents the pricing information for a reserved IP type
type ReservedIPPrice struct {
Hourly float64 `json:"hourly"`
Monthly float64 `json:"monthly"`
}

// ReservedIPRegionPrice represents region-specific pricing for a reserved IP type
type ReservedIPRegionPrice struct {
ID string `json:"id"`
Hourly float64 `json:"hourly"`
Monthly float64 `json:"monthly"`
}

// ReservedIPType represents a reserved IP type with pricing information
type ReservedIPType struct {
ID string `json:"id"`
Label string `json:"label"`
Price ReservedIPPrice `json:"price"`
RegionPrices []ReservedIPRegionPrice `json:"region_prices"`
}

// ListReservedIPAddresses retrieves a list of reserved IP addresses
Expand All @@ -30,9 +58,22 @@ func (c *Client) ReserveIPAddress(ctx context.Context, opts ReserveIPOptions) (*
return doPOSTRequest[InstanceIP](ctx, c, "networking/reserved/ips", opts)
}

// UpdateReservedIPAddress updates the tags of a reserved IP address
// NOTE: Reserved IP feature may not currently be available to all users.
func (c *Client) UpdateReservedIPAddress(ctx context.Context, address string, opts UpdateReservedIPOptions) (*InstanceIP, error) {
e := formatAPIPath("networking/reserved/ips/%s", address)
return doPUTRequest[InstanceIP](ctx, c, e, opts)
}

// DeleteReservedIPAddress deletes a reserved IP address
// NOTE: Reserved IP feature may not currently be available to all users.
func (c *Client) DeleteReservedIPAddress(ctx context.Context, ipAddress string) error {
e := formatAPIPath("networking/reserved/ips/%s", ipAddress)
return doDELETERequest(ctx, c, e)
}

// ListReservedIPTypes retrieves a list of reserved IP types with pricing information
// NOTE: Reserved IP feature may not currently be available to all users.
func (c *Client) ListReservedIPTypes(ctx context.Context, opts *ListOptions) ([]ReservedIPType, error) {
return getPaginatedResults[ReservedIPType](ctx, c, "networking/reserved/ips/types", opts)
}
16 changes: 12 additions & 4 deletions tags.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,11 @@ type TagCreateOptions struct {
Label string `json:"label"`
Linodes []int `json:"linodes,omitempty"`
// @TODO is this implemented?
LKEClusters []int `json:"lke_clusters,omitempty"`
Domains []int `json:"domains,omitempty"`
Volumes []int `json:"volumes,omitempty"`
NodeBalancers []int `json:"nodebalancers,omitempty"`
LKEClusters []int `json:"lke_clusters,omitempty"`
Domains []int `json:"domains,omitempty"`
Volumes []int `json:"volumes,omitempty"`
NodeBalancers []int `json:"nodebalancers,omitempty"`
ReservedIPv4Addresses []string `json:"reserved_ipv4_addresses,omitempty"`
}

// GetCreateOptions converts a Tag to TagCreateOptions for use in CreateTag
Expand Down Expand Up @@ -92,6 +93,13 @@ func (i *TaggedObject) fixData() (*TaggedObject, error) {
return nil, err
}

i.Data = obj
case "reserved_ipv4_address":
obj := InstanceIP{}
if err := json.Unmarshal(i.RawData, &obj); err != nil {
return nil, err
}

i.Data = obj
}

Expand Down
Loading
Loading