Skip to content

Commit 77a1b47

Browse files
authored
feat(upload-artifact): uploads binaries to an oci registry (#22)
* feat(upload-artifact): uploads binaries to an oci registry * feat(upload-artifact): use README.txt in test action * feat(upload-artifact): use local oci registry * feat(upload-artifact): fix sending incorrect size * feat(upload-artifact): add debug logs * feat(upload-artifact): verification only with curl * feat(upload-artifact): add back verification flags * feat(upload-artifact): add config tests + move env to env.go * feat(upload-artifact): use filepath.Rel * feat(upload-artifact): increase upload.go coverage * feat(upload-artifact): create index even if some binary is not uploaded * feat(upload-artifact): increase client.go coverage * feat(upload-artifact): add check to be case insensitive * feat(upload-artifact): GetFileName use stdlib * feat(upload-artifact): update README.md with upload artifact * feat(upload-artifact): back to original .gitignore * fix: merge issue
1 parent e2462fa commit 77a1b47

File tree

23 files changed

+1851
-2
lines changed

23 files changed

+1851
-2
lines changed

.github/workflows/test_action.yml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,11 @@ jobs:
103103
needs: setup-test-tag
104104
name: Test Agent Repository Flow with "v" prefixed version
105105
runs-on: ubuntu-latest
106+
services:
107+
registry:
108+
image: registry:2
109+
ports:
110+
- 5000:5000
106111
steps:
107112
- name: Checkout action repository
108113
uses: actions/checkout@v4
@@ -137,6 +142,21 @@ jobs:
137142
which newrelic-auth-cli
138143
newrelic-auth-cli --help || echo "Mock CLI is ready"
139144
145+
- name: Create test binary artifacts
146+
run: |
147+
echo "::group::Creating test binary files"
148+
mkdir -p dist
149+
150+
# Create a temporary directory with test content to tar
151+
mkdir -p /tmp/test-agent
152+
echo "This is a test binary for OCI upload - $(date)" > /tmp/test-agent/README.txt
153+
echo "agent_version: v0.0.0" > /tmp/test-agent/metadata.txt
154+
155+
# Create actual tar.gz archive
156+
tar -czf dist/test-agent-binary.tar.gz -C /tmp test-agent
157+
158+
echo "::endgroup::"
159+
140160
- name: Run Agent Metadata Action (with configs)
141161
uses: ./
142162
env:
@@ -148,6 +168,42 @@ jobs:
148168
agent-type: 'myagent'
149169
version: 'v0.0.0'
150170
cache: false
171+
oci-registry: 'localhost:5000/test-agents'
172+
oci-username: ''
173+
oci-password: ''
174+
binaries: '[{"name": "test-readme", "path": "./README.md", "os": "linux", "arch": "amd64", "format": "tar+gzip"}]'
175+
176+
- name: Verify OCI upload to local registry
177+
run: |
178+
echo "::group::Verifying uploaded artifact in local registry"
179+
180+
# Check if the manifest exists in the registry
181+
MANIFEST_URL="http://localhost:5000/v2/test-agents/manifests/v0.0.0"
182+
183+
echo "Listing all tags in test-agents repository:"
184+
curl -s -f "http://localhost:5000/v2/test-agents/tags/list" | jq '.'
185+
echo ""
186+
187+
echo "=== Manifest Index (v0.0.0 tag) ==="
188+
# Fetch manifest index with proper OCI accept header
189+
curl -s -f -H "Accept: application/vnd.oci.image.index.v1+json" \
190+
"$MANIFEST_URL" | jq '.'
191+
192+
# Extract the digest of the actual artifact from the index
193+
ARTIFACT_DIGEST=$(curl -s -f -H "Accept: application/vnd.oci.image.index.v1+json" \
194+
"$MANIFEST_URL" | jq -r '.manifests[0].digest')
195+
196+
if [ -n "$ARTIFACT_DIGEST" ] && [ "$ARTIFACT_DIGEST" != "null" ]; then
197+
echo ""
198+
echo "=== Artifact Manifest ($ARTIFACT_DIGEST) ==="
199+
# Fetch artifact manifest by digest
200+
curl -s -f -H "Accept: application/vnd.oci.image.manifest.v1+json" \
201+
"http://localhost:5000/v2/test-agents/manifests/$ARTIFACT_DIGEST" | jq '.'
202+
else
203+
echo "Warning: Could not extract artifact digest from index"
204+
fi
205+
206+
echo "::endgroup::"
151207
152208
- name: Show mock server logs
153209
if: always()

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ coverage.out
1010

1111
# IDE
1212
.idea/
13+
dist/

README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,15 @@ jobs:
4848
agent-type: dotnet-agent # Required for agent release workflow: The type of agent (e.g., nodejs-agent, java-agent)
4949
version: 1.0.0 # Required for agent release workflow: will be used to check out appropriate release tag
5050
cache: true # Optional: Enable Go build cache (default: true)
51+
oci-registry: docker.io/newrelic/agents # Optional: OCI registry URL for binary uploads
52+
oci-username: ${{ github.actor }} # Optional: OCI registry username
53+
oci-password: ${{ secrets.GITHUB_TOKEN }} # Optional: OCI registry password/token
54+
binaries: | # Optional: JSON array with artifact definitions for binary upload
55+
[
56+
{"name": "dotnet-agent", "path": "./dotnet-agent_amd64.tar.gz", "os": "linux", "arch": "amd64", "format": "tar+gzip"},
57+
{"name": "dotnet-agent", "path": "./dotnet-agent_arm64.tar.gz", "os": "linux", "arch": "arm64", "format": "tar+gzip"},
58+
{"name": "dotnet-agent", "path": "./NewRelicDotNetAgent.zip", "os": "windows", "arch": "amd64", "format": "zip"}
59+
]
5160
```
5261

5362
### Example Workflow For Updating Docs Metadata for a new/existing Agent Version
@@ -100,6 +109,27 @@ agentControlDefinitions:
100109

101110
**Paths must be relative to the `.fleetControl` directory and cannot use directory traversal (`..`) for security.
102111

112+
113+
#### Artifact Upload
114+
115+
The agent release workflow supports uploading agent binaries to an OCI registry. This feature is optional and only applies to agent releases (when both `agent-type` and `version` are provided).
116+
117+
To enable binary uploads, provide the following inputs:
118+
- `oci-registry`: OCI registry URL (e.g., `ghcr.io/newrelic/agents`)
119+
- `oci-username`: Registry username for authentication
120+
- `oci-password`: Registry password or token for authentication
121+
- `binaries`: JSON array defining the binaries to upload
122+
123+
**Binaries JSON Format:**
124+
125+
Each entry in the `binaries` array must include:
126+
- `name`: Binary artifact name
127+
- `path`: Path to the binary file (relative to repository root)
128+
- `os`: Operating system (e.g., `linux`, `darwin`, `windows`)
129+
- `arch`: Architecture (e.g., `amd64`, `arm64`)
130+
- `format`: Archive format - supported values: `tar`, `tar+gzip`, `zip`
131+
```
132+
103133
## Building
104134
105135
```bash

action.yml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@ inputs:
1919
description: 'Number of commits to fetch (> 1 may be required for docs flow)'
2020
required: false
2121
default: '1'
22+
oci-registry:
23+
description: 'OCI registry URL for binary uploads (e.g., ghcr.io/newrelic/agents). Leave empty to skip binary upload.'
24+
required: false
25+
default: ''
26+
oci-username:
27+
description: 'OCI registry username (required if oci-registry is set)'
28+
required: false
29+
default: ''
30+
oci-password:
31+
description: 'OCI registry password or token (required if oci-registry is set)'
32+
required: false
33+
default: ''
34+
binaries:
35+
description: 'JSON array with artifact definitions. Each artifact must specify name, path, os, arch, and format. Example: [{"name": "linux-tar", "path": "./dist/agent.tar.gz", "os": "linux", "arch": "amd64", "format": "tar+gzip"}]'
36+
required: false
37+
default: ''
2238
cache:
2339
description: 'Enable Go build cache'
2440
required: false
@@ -161,6 +177,10 @@ runs:
161177
INPUT_AGENT_TYPE: ${{ inputs.agent-type }}
162178
INPUT_VERSION: ${{ inputs.version }}
163179
NEWRELIC_TOKEN: ${{ steps.newrelic-auth.outputs.token }}
180+
INPUT_OCI_REGISTRY: ${{ inputs.oci-registry }}
181+
INPUT_OCI_USERNAME: ${{ inputs.oci-username }}
182+
INPUT_OCI_PASSWORD: ${{ inputs.oci-password }}
183+
INPUT_BINARIES: ${{ inputs.binaries }}
164184
APM_CONTROL_NR_LICENSE_KEY: ${{ inputs.apm-control-nr-license-key }}
165185
run: |
166186
set -e

cmd/agent-metadata-action/main.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import (
1313
"agent-metadata-action/internal/loader"
1414
"agent-metadata-action/internal/logging"
1515
"agent-metadata-action/internal/models"
16-
16+
"agent-metadata-action/internal/oci"
1717
"github.com/newrelic/go-agent/v3/newrelic"
1818
)
1919

@@ -184,6 +184,16 @@ func runAgentFlow(ctx context.Context, client metadataClient, workspace, agentTy
184184
return fmt.Errorf("failed to send metadata for %s: %w", agentType, err)
185185
}
186186

187+
// Handle OCI binary uploads (optional)
188+
ociConfig, err := oci.LoadConfig()
189+
if err != nil {
190+
return fmt.Errorf("error loading OCI config: %w", err)
191+
}
192+
193+
if err := oci.HandleUploads(&ociConfig, workspace, agentType, agentVersion); err != nil {
194+
return fmt.Errorf("binary upload failed: %w", err)
195+
}
196+
187197
logging.Noticef(ctx, "Successfully sent metadata for %s version %s", agentType, agentVersion)
188198
return nil
189199
}

cmd/agent-metadata-action/main_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ func TestMain_AgentRepoFlow(t *testing.T) {
6161
t.Setenv("INPUT_VERSION", "1.2.3")
6262
t.Setenv("GITHUB_WORKSPACE", workspace)
6363
t.Setenv("NEWRELIC_TOKEN", "mock-token-for-testing")
64+
t.Setenv("INPUT_OCI_REGISTRY", "") // Disable OCI for this test
6465

6566
getStdout, getStderr := testutil.CaptureOutput(t)
6667

@@ -468,3 +469,73 @@ func TestSendDocsMetadata(t *testing.T) {
468469
})
469470
}
470471
}
472+
473+
func TestRunAgentFlow_OCIDisabled(t *testing.T) {
474+
projectRoot, err := filepath.Abs("../..")
475+
require.NoError(t, err)
476+
workspace := filepath.Join(projectRoot, "integration-test", "agent-flow")
477+
478+
t.Setenv("INPUT_OCI_REGISTRY", "")
479+
480+
ctx := context.Background()
481+
mockClient := &mockMetadataClient{}
482+
483+
// method under test
484+
err = runAgentFlow(ctx, mockClient, workspace, "java", "1.0.0")
485+
486+
assert.NoError(t, err, "OCI should be skipped when registry is not configured")
487+
}
488+
489+
func TestRunAgentFlow_OCIInvalidConfig(t *testing.T) {
490+
projectRoot, err := filepath.Abs("../..")
491+
require.NoError(t, err)
492+
workspace := filepath.Join(projectRoot, "integration-test", "agent-flow")
493+
494+
t.Setenv("INPUT_OCI_REGISTRY", "ghcr.io/newrelic/agents")
495+
t.Setenv("INPUT_BINARIES", "") // Empty binaries when registry is set = invalid
496+
497+
ctx := context.Background()
498+
mockClient := &mockMetadataClient{}
499+
500+
// method under test
501+
err = runAgentFlow(ctx, mockClient, workspace, "java", "1.0.0")
502+
503+
assert.Error(t, err)
504+
assert.Contains(t, err.Error(), "error loading OCI config")
505+
}
506+
507+
func TestRunAgentFlow_OCIInvalidBinariesJSON(t *testing.T) {
508+
projectRoot, err := filepath.Abs("../..")
509+
require.NoError(t, err)
510+
workspace := filepath.Join(projectRoot, "integration-test", "agent-flow")
511+
512+
t.Setenv("INPUT_OCI_REGISTRY", "docker.io/newrelic/agents")
513+
t.Setenv("INPUT_BINARIES", "not valid json")
514+
515+
ctx := context.Background()
516+
mockClient := &mockMetadataClient{}
517+
518+
// method under test
519+
err = runAgentFlow(ctx, mockClient, workspace, "java", "1.0.0")
520+
521+
assert.Error(t, err)
522+
assert.Contains(t, err.Error(), "error loading OCI config")
523+
}
524+
525+
func TestRunAgentFlow_OCIMissingBinaryFile(t *testing.T) {
526+
projectRoot, err := filepath.Abs("../..")
527+
require.NoError(t, err)
528+
workspace := filepath.Join(projectRoot, "integration-test", "agent-flow")
529+
530+
t.Setenv("INPUT_OCI_REGISTRY", "ghcr.io/newrelic/agents")
531+
t.Setenv("INPUT_BINARIES", `[{"name":"test","path":"./nonexistent.tar.gz","os":"linux","arch":"amd64","format":"tar+gzip"}]`)
532+
533+
ctx := context.Background()
534+
mockClient := &mockMetadataClient{}
535+
536+
// method under test
537+
err = runAgentFlow(ctx, mockClient, workspace, "java", "1.0.0")
538+
539+
assert.Error(t, err)
540+
assert.Contains(t, err.Error(), "binary upload failed")
541+
}

go.mod

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,19 @@ module agent-metadata-action
33
go 1.25.3
44

55
require (
6+
github.com/newrelic/go-agent/v3 v3.42.0
7+
github.com/opencontainers/go-digest v1.0.0
8+
github.com/opencontainers/image-spec v1.1.1
69
github.com/stretchr/testify v1.11.1
710
gopkg.in/yaml.v3 v3.0.1
11+
oras.land/oras-go/v2 v2.6.0
812
)
913

1014
require (
1115
github.com/davecgh/go-spew v1.1.1 // indirect
12-
github.com/newrelic/go-agent/v3 v3.42.0 // indirect
1316
github.com/pmezard/go-difflib v1.0.0 // indirect
1417
golang.org/x/net v0.25.0 // indirect
18+
golang.org/x/sync v0.14.0 // indirect
1519
golang.org/x/sys v0.20.0 // indirect
1620
golang.org/x/text v0.15.0 // indirect
1721
google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
22
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3+
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
4+
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
35
github.com/newrelic/go-agent/v3 v3.42.0 h1:aA2Ea1RT5eD59LtOS1KGFXSmaDs6kM3Jeqo7PpuQoFQ=
46
github.com/newrelic/go-agent/v3 v3.42.0/go.mod h1:sCgxDCVydoKD/C4S8BFxDtmFHvdWHtaIz/a3kiyNB/k=
7+
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
8+
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
9+
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
10+
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
511
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
612
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
713
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
814
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
915
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
1016
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
17+
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
18+
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
1119
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
1220
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
1321
golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk=
@@ -22,3 +30,5 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+
2230
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2331
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2432
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
33+
oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc=
34+
oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=

internal/config/env.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,26 @@ func GetToken() string {
3434
return os.Getenv("NEWRELIC_TOKEN")
3535
}
3636

37+
// GetOCIRegistry loads the OCI registry from environment variables
38+
func GetOCIRegistry() string {
39+
return os.Getenv("INPUT_OCI_REGISTRY")
40+
}
41+
42+
// GetOCIUsername loads the OCI username from environment variables
43+
func GetOCIUsername() string {
44+
return os.Getenv("INPUT_OCI_USERNAME")
45+
}
46+
47+
// GetOCIPassword loads the OCI password from environment variables
48+
func GetOCIPassword() string {
49+
return os.Getenv("INPUT_OCI_PASSWORD")
50+
}
51+
52+
// GetBinaries loads the binaries JSON from environment variables
53+
func GetBinaries() string {
54+
return os.Getenv("INPUT_BINARIES")
55+
}
56+
3757
// GetNRAgentLicenseKey gets the license key to use the go agent and monitor this app
3858
func GetNRAgentLicenseKey() string {
3959
return os.Getenv("APM_CONTROL_NR_LICENSE_KEY")

0 commit comments

Comments
 (0)