Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
8 changes: 8 additions & 0 deletions client/go/internal/cli/cmd/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,14 @@ $ vespa deploy -t cloud -z dev.gcp-us-central1-f`,
return err
}
}
if err == nil {
if vaultNames := services.VaultNames(); len(vaultNames) > 0 {
if vaultErr := vespa.EnsureVaultAccessForDev(target, vaultNames); vaultErr != nil {
cli.printWarning("Could not set up vault access: "+vaultErr.Error(),
"You may need to configure vault access manually in the Vespa Cloud console")
}
}
}
}
waiter := cli.waiter(time.Duration(waitSecs)*time.Second, cmd)
if _, err := waiter.DeployService(target); err != nil {
Expand Down
156 changes: 156 additions & 0 deletions client/go/internal/vespa/vault.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package vespa

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)

const secretStoreDevAlias = "SANDBOX"

type vaultAccessRule struct {
Application string `json:"application"`
Contexts []string `json:"contexts"`
ID int `json:"id"`
}

type vaultResponse struct {
Rules []vaultAccessRule `json:"rules"`
}

func (t *cloudTarget) vaultAccessURL(tenant, vaultName string) string {
return fmt.Sprintf("%s/tenant-secret/v1/tenant/%s/vault/%s", t.apiOptions.System.URL, tenant, vaultName)
}

func (t *cloudTarget) csrfToken() (string, error) {
req, err := http.NewRequest("GET", fmt.Sprintf("%s/csrf/v1", t.apiOptions.System.URL), nil)
if err != nil {
return "", err
}
deployService, err := t.DeployService()
if err != nil {
return "", err
}
resp, err := deployService.Do(req, 10*time.Second)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return "", fmt.Errorf("CSRF endpoint returned %d: %s", resp.StatusCode, body)
}
var result struct {
Token string `json:"token"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", err
}
return result.Token, nil
Comment on lines +29 to +53
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

csrfToken() decodes the response body without checking resp.StatusCode first. If the CSRF endpoint returns an error status (e.g. 401/403/500), this will likely surface as a JSON decode error or return an empty token. Check resp.StatusCode and return a helpful error (including body) for non-success responses.

Copilot uses AI. Check for mistakes.
}

func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error {
deployment := t.deploymentOptions.Deployment
tenant := deployment.Application.Tenant
appID := deployment.Application.Application // just the application name; tenant is already in the URL path
vaultURL := t.vaultAccessURL(tenant, vaultName)

// GET existing rules
req, err := http.NewRequest("GET", vaultURL, nil)
if err != nil {
return err
}
deployService, err := t.DeployService()
if err != nil {
return err
}
resp, err := deployService.Do(req, 10*time.Second)
if err != nil {
return fmt.Errorf("could not get vault access rules for %q: %w", vaultName, err)
}
defer resp.Body.Close()
getRawBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode/100 != 2 {
return fmt.Errorf("could not get vault access rules for %q: server returned %d: %s", vaultName, resp.StatusCode, getRawBody)
}
var vaultResp vaultResponse
if err := json.Unmarshal(getRawBody, &vaultResp); err != nil {
return fmt.Errorf("could not parse vault access rules for %q: %w", vaultName, err)
}
Comment on lines +71 to +83
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

HTTP status codes are not checked for either the GET or PUT requests. As written, a non-200 response (e.g. 403/404/500) will still be read and unmarshaled, producing confusing JSON errors or silently proceeding with an empty rule set. Check resp.StatusCode/putResp.StatusCode and return a helpful error (including response body) when the status is not successful.

Copilot uses AI. Check for mistakes.

// Check if access rule already exists for this application with the dev alias
for _, rule := range vaultResp.Rules {
if rule.Application == appID {
for _, ctx := range rule.Contexts {
if ctx == secretStoreDevAlias {
return nil
}
}
}
}

// Build new rule with no context restriction (grants access to all environments)
newRule := vaultAccessRule{
Application: appID,
Contexts: []string{secretStoreDevAlias},
ID: len(vaultResp.Rules),
}
Comment on lines +96 to +101
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

The comment says the new rule has “no context restriction (grants access to all environments)”, but the code sets Contexts to []string{SECRET_STORE_DEV_ALIAS} (SANDBOX), which is a restriction. Update the comment to match behavior (or adjust the Contexts logic if the intent really is all environments).

Copilot uses AI. Check for mistakes.
updatedRules := vaultResponse{Rules: append(vaultResp.Rules, newRule)}
body, err := json.Marshal(updatedRules)
if err != nil {
return err
}

csrfToken, err := t.csrfToken()
if err != nil {
return fmt.Errorf("could not fetch CSRF token: %w", err)
}

// PUT updated rules
putReq, err := http.NewRequest("PUT", vaultURL, bytes.NewReader(body))
if err != nil {
return err
}
putReq.Header.Set("Content-Type", "application/json")
if csrfToken != "" {
putReq.Header.Set("vespa-csrf-token", csrfToken)
}
deployService2, err := t.DeployService()
if err != nil {
return err
}
putResp, err := deployService2.Do(putReq, 10*time.Second)
if err != nil {
return fmt.Errorf("could not set vault access rule for %q: %w", vaultName, err)
}
defer putResp.Body.Close()
putRawBody, _ := io.ReadAll(putResp.Body)
if putResp.StatusCode/100 != 2 {
return fmt.Errorf("could not set vault access rule for %q: server returned %d: %s", vaultName, putResp.StatusCode, putRawBody)
}
return nil
}

func (t *cloudTarget) ensureVaultAccessForDev(vaultNames []string) error {
for _, name := range vaultNames {
if err := t.ensureVaultAccessRule(name); err != nil {
return err
}
}
return nil
}

// EnsureVaultAccessForDev checks and sets vault access rules for dev deployments.
// Returns nil for non-cloud targets or when no vaults are referenced.
// Errors are non-fatal warnings for the caller.
func EnsureVaultAccessForDev(target Target, vaultNames []string) error {
ct, ok := target.(*cloudTarget)
if !ok || len(vaultNames) == 0 || ct.deploymentOptions.Deployment.Zone.Environment != "dev" {
return nil
Comment on lines +149 to +153
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

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

EnsureVaultAccessForDev is documented/named as applying to dev deployments, but it currently runs for any cloud deployment (including prod) as long as vaultNames is non-empty. Add a guard on target.Deployment().Zone.Environment (or equivalent) so this only mutates SANDBOX/dev access when deploying to dev.

Suggested change
// Errors are non-fatal warnings for the caller.
func EnsureVaultAccessForDev(target Target, vaultNames []string) error {
ct, ok := target.(*cloudTarget)
if !ok || len(vaultNames) == 0 {
return nil
// Returns nil for non-cloud targets, non-dev environments, or when no vaults are referenced.
// Errors are non-fatal warnings for the caller.
func EnsureVaultAccessForDev(target Target, vaultNames []string) error {
if len(vaultNames) == 0 {
return nil
}
deployment := target.Deployment()
if deployment == nil || deployment.Zone.Environment != "dev" {
// Only modify SANDBOX/dev access rules when deploying to dev.
return nil
}
ct, ok := target.(*cloudTarget)
if !ok {

Copilot uses AI. Check for mistakes.
}
return ct.ensureVaultAccessForDev(vaultNames)
}
85 changes: 85 additions & 0 deletions client/go/internal/vespa/vault_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
// Copyright Vespa.ai. Licensed under the terms of the Apache 2.0 license. See LICENSE in the project root.
package vespa

import (
"encoding/json"
"io"
"net/http"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/vespa-engine/vespa/client/go/internal/mock"
)

func TestEnsureVaultAccessForDevNonCloud(t *testing.T) {
// Non-cloud targets are a no-op
client := &mock.HTTPClient{}
lt := LocalTarget(client, TLSOptions{}, 0)
err := EnsureVaultAccessForDev(lt, []string{"my-vault"})
assert.Nil(t, err)
assert.Empty(t, client.Requests)
}

func TestEnsureVaultAccessForDevNoVaults(t *testing.T) {
target, client := createCloudTarget(t, io.Discard)
err := EnsureVaultAccessForDev(target, nil)
assert.Nil(t, err)
assert.Empty(t, client.Requests)
}

func TestEnsureVaultAccessForDevAlreadySet(t *testing.T) {
target, client := createCloudTarget(t, io.Discard)
// GET response: rule already present for t1.a1.i1 with "dev" context
existingRules := vaultResponse{
Rules: []vaultAccessRule{
{Application: "a1", Contexts: []string{"SANDBOX"}, ID: 0},
},
}
body, _ := json.Marshal(existingRules)
client.NextResponse(mock.HTTPResponse{Status: 200, Body: body})

err := EnsureVaultAccessForDev(target, []string{"my-vault"})
assert.Nil(t, err)
require.Len(t, client.Requests, 1)
assert.Equal(t, http.MethodGet, client.Requests[0].Method)
}

func TestEnsureVaultAccessForDevAddsRule(t *testing.T) {
target, client := createCloudTarget(t, io.Discard)
client.ReadBody = true

// GET: no existing rules
emptyRules := vaultResponse{Rules: []vaultAccessRule{}}
getBody, _ := json.Marshal(emptyRules)
client.NextResponse(mock.HTTPResponse{Status: 200, Body: getBody})

// CSRF GET
csrfBody, _ := json.Marshal(map[string]string{"token": "test-csrf"})
client.NextResponse(mock.HTTPResponse{Status: 200, Body: csrfBody})

// PUT response with new rule confirmed
updatedRules := vaultResponse{
Rules: []vaultAccessRule{
{Application: "a1", Contexts: []string{"SANDBOX"}, ID: 0},
},
}
putBody, _ := json.Marshal(updatedRules)
client.NextResponse(mock.HTTPResponse{Status: 200, Body: putBody})

err := EnsureVaultAccessForDev(target, []string{"my-vault"})
assert.Nil(t, err)
require.Len(t, client.Requests, 3)
assert.Equal(t, http.MethodGet, client.Requests[0].Method)
assert.Equal(t, http.MethodGet, client.Requests[1].Method) // CSRF
assert.Equal(t, http.MethodPut, client.Requests[2].Method)
assert.Equal(t, "test-csrf", client.Requests[2].Header.Get("vespa-csrf-token"))
assert.Equal(t, "application/json", client.Requests[2].Header.Get("Content-Type"))
}

func TestEnsureVaultAccessForDevGetError(t *testing.T) {
target, client := createCloudTarget(t, io.Discard)
client.NextResponseError(io.EOF)
err := EnsureVaultAccessForDev(target, []string{"my-vault"})
assert.NotNil(t, err)
}
34 changes: 34 additions & 0 deletions client/go/internal/vespa/xml/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"

Expand Down Expand Up @@ -119,6 +120,39 @@ func (s *Services) Replace(parentName, name string, data interface{}) error {
return nil
}

// VaultNames returns the names of all vaults referenced in <secrets> elements of services.xml.
func (s Services) VaultNames() []string {
dec := xml.NewDecoder(bytes.NewReader(s.rawXML.Bytes()))
var names []string
seen := map[string]bool{}
inSecrets := 0
for {
tok, err := dec.Token()
if err != nil {
break
}
switch t := tok.(type) {
case xml.StartElement:
if t.Name.Local == "secrets" {
inSecrets++
} else if inSecrets > 0 {
for _, attr := range t.Attr {
if attr.Name.Local == "vault" && attr.Value != "" && !seen[attr.Value] {
seen[attr.Value] = true
names = append(names, attr.Value)
}
}
}
case xml.EndElement:
if t.Name.Local == "secrets" && inSecrets > 0 {
inSecrets--
}
}
}
sort.Strings(names)
return names
}

func (s *Services) ContainsAnyTokenClient() bool {
for _, container := range s.Container {
for _, client := range container.Clients {
Expand Down
45 changes: 45 additions & 0 deletions client/go/internal/vespa/xml/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,51 @@ func assertNodeCount(t *testing.T, input string, wantMin, wantMax int, wantErr b
}
}

func TestVaultNames(t *testing.T) {
// No secrets element
s, err := ReadServices(strings.NewReader(`<services><container id="c"><nodes count="1"/></container></services>`))
if err != nil {
t.Fatal(err)
}
if got := s.VaultNames(); len(got) != 0 {
t.Errorf("expected no vault names, got %v", got)
}

// Single secret with vault
s, err = ReadServices(strings.NewReader(`<services>
<container id="c">
<secrets>
<secret vault="my-vault" name="foo"/>
</secrets>
<nodes count="1"/>
</container>
</services>`))
if err != nil {
t.Fatal(err)
}
if got := s.VaultNames(); !reflect.DeepEqual(got, []string{"my-vault"}) {
t.Errorf("expected [my-vault], got %v", got)
}

// Multiple vaults (deduplicated and sorted)
s, err = ReadServices(strings.NewReader(`<services>
<container id="c">
<secrets>
<secret vault="vault-b" name="foo"/>
<secret vault="vault-a" name="bar"/>
<secret vault="vault-b" name="baz"/>
</secrets>
<nodes count="1"/>
</container>
</services>`))
if err != nil {
t.Fatal(err)
}
if got := s.VaultNames(); !reflect.DeepEqual(got, []string{"vault-a", "vault-b"}) {
t.Errorf("expected [vault-a vault-b], got %v", got)
}
}

func assertResources(t *testing.T, input string, want Resources, wantErr bool) {
got, err := ParseResources(input)
if wantErr {
Expand Down
Loading