Skip to content

Commit fb80cbe

Browse files
ostermanclaudeaknysh
authored
Fix Artifactory store and improve store documentation (#2038)
* docs: add blog post for Artifactory store fix and documentation corrections * fix: use struct-based JSON marshaling for AQL response Replace unsafe string formatting with struct-based JSON marshaling to address CodeQL security alert about potentially unsafe quoting. Uses struct field ordering to ensure "results" comes before "range" in the JSON output, which is required by the JFrog SDK's ContentReader. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * docs: rewrite blog post for end users Focus on the user-facing problem (Artifactory retrieval failing with nested paths) rather than internal implementation details. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: use path.Join for Artifactory repo paths (Windows compatibility) On Windows, filepath.Join uses backslashes which breaks Artifactory API paths. Use path.Join which always uses forward slashes for URL paths. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Andriy Knysh <aknysh@users.noreply.github.com>
1 parent 34ef473 commit fb80cbe

File tree

8 files changed

+850
-65
lines changed

8 files changed

+850
-65
lines changed

pkg/store/artifactory_store.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"encoding/json"
66
"fmt"
77
"os"
8+
"path"
89
"path/filepath"
910
"strings"
1011
"time"
@@ -201,6 +202,7 @@ func (s *ArtifactoryStore) Get(stack string, component string, key string) (inte
201202
downloadParams.Target = tempDir
202203
downloadParams.Recursive = false
203204
downloadParams.IncludeDirs = false
205+
downloadParams.Flat = true
204206

205207
totalDownloaded, totalExpected, err := s.rtManager.DownloadFiles(downloadParams)
206208
if err != nil {
@@ -306,8 +308,10 @@ func (s *ArtifactoryStore) GetKey(key string) (interface{}, error) {
306308
filePath += ".json"
307309
}
308310

309-
// Construct the full repository path
310-
repoPath := filepath.Join(s.repoName, filePath)
311+
// Construct the full repository path.
312+
// Use path.Join (not filepath.Join) because this is a URL path for the Artifactory API,
313+
// which requires forward slashes on all platforms including Windows.
314+
repoPath := path.Join(s.repoName, filePath) //nolint:forbidigo // URL path requires forward slashes
311315

312316
// Create a temporary directory to download the file
313317
tempDir, err := os.MkdirTemp("", "atmos-artifactory-*")
@@ -320,12 +324,19 @@ func (s *ArtifactoryStore) GetKey(key string) (interface{}, error) {
320324
}
321325
}()
322326

327+
// JFrog SDK requires trailing separator for directory targets.
328+
tempDir = filepath.Clean(tempDir)
329+
if !strings.HasSuffix(tempDir, string(os.PathSeparator)) {
330+
tempDir += string(os.PathSeparator)
331+
}
332+
323333
// Download the file from Artifactory
324334
downloadParams := services.NewDownloadParams()
325335
downloadParams.Pattern = repoPath
326336
downloadParams.Target = tempDir
327337
downloadParams.Recursive = false
328338
downloadParams.IncludeDirs = false
339+
downloadParams.Flat = true
329340

330341
_, _, err = s.rtManager.DownloadFiles(downloadParams)
331342
if err != nil {

tests/store_artifactory_test.go

Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
package tests
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"testing"
7+
8+
jfroglog "github.com/jfrog/jfrog-client-go/utils/log"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
12+
"github.com/cloudposse/atmos/pkg/store"
13+
"github.com/cloudposse/atmos/tests/testhelpers/httpmock"
14+
)
15+
16+
func init() {
17+
// Enable JFrog SDK debug logging.
18+
jfroglog.SetLogger(jfroglog.NewLogger(jfroglog.DEBUG, os.Stderr))
19+
}
20+
21+
// TestArtifactoryStoreIntegration tests the Artifactory store with a mock HTTP server
22+
// that implements enough of the Artifactory Generic repository API to verify
23+
// the store's Set and Get operations work correctly.
24+
func TestArtifactoryStoreIntegration(t *testing.T) {
25+
// Skip if running short tests.
26+
if testing.Short() {
27+
t.Skip("Skipping integration test in short mode")
28+
}
29+
30+
// Create mock Artifactory server.
31+
mockServer := httpmock.NewArtifactoryMockServer(t)
32+
33+
// Create store pointing to mock server.
34+
prefix := "atmos"
35+
delimiter := "/"
36+
artifactoryStore, err := store.NewArtifactoryStore(store.ArtifactoryStoreOptions{
37+
URL: mockServer.URL(),
38+
RepoName: "test-repo",
39+
AccessToken: strPtr("test-token"),
40+
Prefix: &prefix,
41+
StackDelimiter: &delimiter,
42+
})
43+
require.NoError(t, err)
44+
require.NotNil(t, artifactoryStore)
45+
46+
t.Run("Set and Get simple value", func(t *testing.T) {
47+
// Arrange.
48+
stack := "dev"
49+
component := "vpc"
50+
key := "vpc_id"
51+
value := "vpc-12345"
52+
53+
// Act - Set the value.
54+
err := artifactoryStore.Set(stack, component, key, value)
55+
require.NoError(t, err)
56+
57+
// Assert - Verify the file was stored in the mock.
58+
expectedPath := "test-repo/atmos/dev/vpc/vpc_id"
59+
storedData, exists := mockServer.GetFile(expectedPath)
60+
require.True(t, exists, "File should exist at path: %s", expectedPath)
61+
62+
// Verify stored content is JSON.
63+
var storedValue string
64+
err = json.Unmarshal(storedData, &storedValue)
65+
require.NoError(t, err)
66+
assert.Equal(t, value, storedValue)
67+
68+
// Act - Get the value back.
69+
result, err := artifactoryStore.Get(stack, component, key)
70+
require.NoError(t, err)
71+
assert.Equal(t, value, result)
72+
})
73+
74+
t.Run("Set and Get complex value", func(t *testing.T) {
75+
// Arrange.
76+
stack := "prod"
77+
component := "network"
78+
key := "config"
79+
value := map[string]interface{}{
80+
"vpc_id": "vpc-67890",
81+
"subnet_ids": []interface{}{"subnet-1", "subnet-2", "subnet-3"},
82+
"tags": map[string]interface{}{
83+
"Environment": "production",
84+
"Team": "platform",
85+
},
86+
}
87+
88+
// Act - Set the value.
89+
err := artifactoryStore.Set(stack, component, key, value)
90+
require.NoError(t, err)
91+
92+
// Act - Get the value back.
93+
result, err := artifactoryStore.Get(stack, component, key)
94+
require.NoError(t, err)
95+
96+
// Assert - Compare as JSON to handle type differences.
97+
expectedJSON, _ := json.Marshal(value)
98+
actualJSON, _ := json.Marshal(result)
99+
assert.JSONEq(t, string(expectedJSON), string(actualJSON))
100+
})
101+
102+
t.Run("Get non-existent key returns error", func(t *testing.T) {
103+
// Act.
104+
result, err := artifactoryStore.Get("nonexistent", "component", "key")
105+
106+
// Assert.
107+
assert.Error(t, err)
108+
assert.Nil(t, result)
109+
})
110+
111+
t.Run("Set with nested component path", func(t *testing.T) {
112+
// Arrange.
113+
stack := "staging"
114+
component := "app/backend/api"
115+
key := "endpoint"
116+
value := "https://api.example.com"
117+
118+
// Act.
119+
err := artifactoryStore.Set(stack, component, key, value)
120+
require.NoError(t, err)
121+
122+
// Assert - Verify the file path is correct.
123+
expectedPath := "test-repo/atmos/staging/app/backend/api/endpoint"
124+
_, exists := mockServer.GetFile(expectedPath)
125+
require.True(t, exists, "File should exist at path: %s", expectedPath)
126+
127+
// Verify we can retrieve it.
128+
result, err := artifactoryStore.Get(stack, component, key)
129+
require.NoError(t, err)
130+
assert.Equal(t, value, result)
131+
})
132+
133+
t.Run("Set with multi-level stack", func(t *testing.T) {
134+
// Arrange.
135+
stack := "plat/ue2/dev"
136+
component := "vpc"
137+
key := "cidr"
138+
value := "10.0.0.0/16"
139+
140+
// Act.
141+
err := artifactoryStore.Set(stack, component, key, value)
142+
require.NoError(t, err)
143+
144+
// Assert - Verify the file path uses the stack delimiter.
145+
expectedPath := "test-repo/atmos/plat/ue2/dev/vpc/cidr"
146+
_, exists := mockServer.GetFile(expectedPath)
147+
require.True(t, exists, "File should exist at path: %s", expectedPath)
148+
149+
// Verify we can retrieve it.
150+
result, err := artifactoryStore.Get(stack, component, key)
151+
require.NoError(t, err)
152+
assert.Equal(t, value, result)
153+
})
154+
155+
t.Run("Set empty stack returns error", func(t *testing.T) {
156+
err := artifactoryStore.Set("", "component", "key", "value")
157+
assert.Error(t, err)
158+
})
159+
160+
t.Run("Set empty component returns error", func(t *testing.T) {
161+
err := artifactoryStore.Set("stack", "", "key", "value")
162+
assert.Error(t, err)
163+
})
164+
165+
t.Run("Set empty key returns error", func(t *testing.T) {
166+
err := artifactoryStore.Set("stack", "component", "", "value")
167+
assert.Error(t, err)
168+
})
169+
170+
t.Run("Set nil value returns error", func(t *testing.T) {
171+
err := artifactoryStore.Set("stack", "component", "key", nil)
172+
assert.Error(t, err)
173+
})
174+
}
175+
176+
// TestArtifactoryStoreGetKey tests the GetKey method for direct key access.
177+
func TestArtifactoryStoreGetKey(t *testing.T) {
178+
// Skip if running short tests.
179+
if testing.Short() {
180+
t.Skip("Skipping integration test in short mode")
181+
}
182+
183+
// Create mock Artifactory server.
184+
mockServer := httpmock.NewArtifactoryMockServer(t)
185+
186+
// Create store pointing to mock server.
187+
prefix := "config"
188+
artifactoryStore, err := store.NewArtifactoryStore(store.ArtifactoryStoreOptions{
189+
URL: mockServer.URL(),
190+
RepoName: "test-repo",
191+
AccessToken: strPtr("test-token"),
192+
Prefix: &prefix,
193+
})
194+
require.NoError(t, err)
195+
196+
t.Run("GetKey retrieves direct key", func(t *testing.T) {
197+
// Arrange - Pre-populate the mock with a file.
198+
value := map[string]interface{}{
199+
"setting1": "value1",
200+
"setting2": 42,
201+
}
202+
content, _ := json.Marshal(value)
203+
mockServer.SetFile("test-repo/config/global-settings.json", content)
204+
205+
// Act.
206+
result, err := artifactoryStore.GetKey("global-settings")
207+
require.NoError(t, err)
208+
209+
// Assert.
210+
expectedJSON, _ := json.Marshal(value)
211+
actualJSON, _ := json.Marshal(result)
212+
assert.JSONEq(t, string(expectedJSON), string(actualJSON))
213+
})
214+
215+
t.Run("GetKey with nested path", func(t *testing.T) {
216+
// Arrange.
217+
value := "production-api-key"
218+
content, _ := json.Marshal(value)
219+
mockServer.SetFile("test-repo/config/secrets/api-key.json", content)
220+
221+
// Act.
222+
result, err := artifactoryStore.GetKey("secrets/api-key")
223+
require.NoError(t, err)
224+
225+
// Assert.
226+
assert.Equal(t, value, result)
227+
})
228+
}
229+
230+
// TestArtifactoryStoreWithoutPrefix tests store behavior without a prefix.
231+
func TestArtifactoryStoreWithoutPrefix(t *testing.T) {
232+
// Skip if running short tests.
233+
if testing.Short() {
234+
t.Skip("Skipping integration test in short mode")
235+
}
236+
237+
// Create mock Artifactory server.
238+
mockServer := httpmock.NewArtifactoryMockServer(t)
239+
240+
// Create store without prefix.
241+
delimiter := "/"
242+
artifactoryStore, err := store.NewArtifactoryStore(store.ArtifactoryStoreOptions{
243+
URL: mockServer.URL(),
244+
RepoName: "my-repo",
245+
AccessToken: strPtr("test-token"),
246+
StackDelimiter: &delimiter,
247+
})
248+
require.NoError(t, err)
249+
250+
t.Run("Set without prefix", func(t *testing.T) {
251+
// Arrange.
252+
stack := "dev"
253+
component := "app"
254+
key := "version"
255+
value := "1.0.0"
256+
257+
// Act.
258+
err := artifactoryStore.Set(stack, component, key, value)
259+
require.NoError(t, err)
260+
261+
// Assert - Path should have empty prefix (just repo/stack/component/key).
262+
expectedPath := "my-repo/dev/app/version"
263+
_, exists := mockServer.GetFile(expectedPath)
264+
require.True(t, exists, "File should exist at path: %s\nAll files: %v", expectedPath, mockServer.ListFiles())
265+
})
266+
}
267+
268+
func strPtr(s string) *string {
269+
return &s
270+
}

0 commit comments

Comments
 (0)