Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
35b2878
build(deps): bump golang.org/x/oauth2 from 0.35.0 to 0.36.0 (#909)
dependabot[bot] Mar 13, 2026
cf20b58
TPT-4298: Added PR title checking to lint workflow and new clean up r…
ezilber-akamai Mar 17, 2026
76d9391
TPT-4014: Redact sensitive data from logging (#906)
dawiddzhafarov Mar 20, 2026
802f57f
TPT-4277 Implement support for Reserved IP for IPv4
mgwoj Mar 23, 2026
3d04bdc
Update network_reserved_ips.go
mgwoj Mar 23, 2026
2264a2e
TPT-4277 Implement support for Reserved IP for IPv4
mgwoj Mar 23, 2026
dc00ea0
TPT-4320: Update FirewallID to use single pointer in LinodeInterfaceC…
zliang-akamai Mar 24, 2026
245707c
build(deps): bump slackapi/slack-github-action from 2.1.1 to 3.0.1 (#…
dependabot[bot] Mar 25, 2026
4a2a3ed
build(deps): bump actions/github-script from 7 to 8 (#918)
dependabot[bot] Mar 25, 2026
492a403
TPT-4277 Implement support for Reserved IP for IPv4
mgwoj Mar 26, 2026
0606e68
TPT-4277 Implement support for Reserved IP for IPv4
mgwoj Mar 26, 2026
fb8c404
TPT-4318: Add @linode/dx-sdets to CODEOWNERS (#920)
lgarber-akamai Mar 26, 2026
69df217
TPT-3807: Added `DiskEncryption` field for LKE Node Pool creation (#917)
ezilber-akamai Mar 27, 2026
6a5e955
build(deps): bump golang.org/x/net from 0.51.0 to 0.52.0 (#911)
dependabot[bot] Mar 27, 2026
2b51e3c
TPT-4234: Fix firewall device for linode interfaces and add entities …
zliang-akamai Mar 27, 2026
09803f2
Cleanup LA notices for block storage encryption (#902)
zliang-akamai Mar 27, 2026
04c1c9e
Remove content field from list alert channels response (#925)
shkaruna Apr 8, 2026
ad25985
feat: add ACLP list entities method (#923)
shkaruna Apr 9, 2026
f1eb655
Merge branch 'linode:main' into feature/TPT-4277-linodego-implement-s…
mgwoj Apr 15, 2026
e150bbf
TPT-4277: Implement support for Reserved IP for IPv4
mgwoj Apr 15, 2026
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:
Comment thread
mgwoj marked this conversation as resolved.
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
26 changes: 14 additions & 12 deletions instance_ips.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,20 @@ type InstanceIPv4Response struct {

// InstanceIP represents an Instance IP with additional DNS and networking details
type InstanceIP struct {
Address string `json:"address"`
Gateway string `json:"gateway"`
SubnetMask string `json:"subnet_mask"`
Prefix int `json:"prefix"`
Type InstanceIPType `json:"type"`
Public bool `json:"public"`
RDNS string `json:"rdns"`
LinodeID int `json:"linode_id"`
InterfaceID *int `json:"interface_id"`
Region string `json:"region"`
VPCNAT1To1 *InstanceIPNAT1To1 `json:"vpc_nat_1_1"`
Reserved bool `json:"reserved"`
Address string `json:"address"`
Gateway string `json:"gateway"`
SubnetMask string `json:"subnet_mask"`
Prefix int `json:"prefix"`
Type InstanceIPType `json:"type"`
Public bool `json:"public"`
RDNS string `json:"rdns"`
LinodeID int `json:"linode_id"`
InterfaceID *int `json:"interface_id"`
Region string `json:"region"`
VPCNAT1To1 *InstanceIPNAT1To1 `json:"vpc_nat_1_1"`
Reserved bool `json:"reserved"`
Tags []string `json:"tags"`
AssignedEntity *ReservedIPAssignedEntity `json:"assigned_entity"`
Comment thread
mgwoj marked this conversation as resolved.
}

// 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 @@ -4,12 +4,40 @@ import (
"context"
)

// ReservedIPAssignedEntity represents the entity that a reserved IP is assigned to.
// NOTE: Reserved IP feature may not currently be available to all users.
type ReservedIPAssignedEntity struct {
ID int `json:"id"`
Label string `json:"label"`
Type string `json:"type"`
URL string `json:"url"`
}

// 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"`
}
Comment on lines +23 to 27
Copy link

Copilot AI Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

UpdateReservedIPOptions.Tags is a non-pointer slice without omitempty, so the zero value (nil) will marshal as "tags":null. If the API expects an array (even when clearing), this can cause request validation errors or unintended semantics. Consider using Tags *[]string json:"tags,omitempty"`` (consistent with other update options in the SDK) or validate that Tags is non-nil before issuing the PUT.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

@yec-akamai yec-akamai Apr 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can use omitzero instead of omitempty for any list to make sure empty slice are correctly marshalled.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, this is the way to go


// ReservedIPPrice represents the pricing information for a reserved IP type.
// It is an alias of the shared baseTypePrice to keep pricing consistent across resources.
type ReservedIPPrice = baseTypePrice

// ReservedIPRegionPrice represents region-specific pricing for a reserved IP type.
// It is an alias of the shared baseTypeRegionPrice to keep region pricing consistent across resources.
type ReservedIPRegionPrice = baseTypeRegionPrice

// ReservedIPType represents a reserved IP type with pricing information.
// It reuses the generic baseType to avoid duplicating type/pricing structures.
type ReservedIPType = baseType[ReservedIPPrice, ReservedIPRegionPrice]

// ListReservedIPAddresses retrieves a list of reserved IP addresses
// NOTE: Reserved IP feature may not currently be available to all users.
func (c *Client) ListReservedIPAddresses(ctx context.Context, opts *ListOptions) ([]InstanceIP, error) {
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)
}
Loading
Loading