From 3f14cff5ff3be9e9f5a25a0c560fc3b3fa34f19a Mon Sep 17 00:00:00 2001 From: Brage Date: Fri, 6 Mar 2026 13:35:45 +0100 Subject: [PATCH 1/3] feat: automatically adds acces to the vault when deploying dev --- client/go/internal/cli/cmd/deploy.go | 8 + client/go/internal/vespa/vault.go | 159 ++++++++++++++++++++ client/go/internal/vespa/vault_test.go | 85 +++++++++++ client/go/internal/vespa/xml/config.go | 34 +++++ client/go/internal/vespa/xml/config_test.go | 45 ++++++ 5 files changed, 331 insertions(+) create mode 100644 client/go/internal/vespa/vault.go create mode 100644 client/go/internal/vespa/vault_test.go diff --git a/client/go/internal/cli/cmd/deploy.go b/client/go/internal/cli/cmd/deploy.go index 9bd6dd5b12dc..94c79b85d062 100644 --- a/client/go/internal/cli/cmd/deploy.go +++ b/client/go/internal/cli/cmd/deploy.go @@ -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 { diff --git a/client/go/internal/vespa/vault.go b/client/go/internal/vespa/vault.go new file mode 100644 index 000000000000..2bfcdeb11dc2 --- /dev/null +++ b/client/go/internal/vespa/vault.go @@ -0,0 +1,159 @@ +// 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 SECRET_STORE_DEV_ALIAS = "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() + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.Token, nil +} + +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) + 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) + } + + // 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 == SECRET_STORE_DEV_ALIAS { + return nil + } + } + } + } + + // Build new rule with no context restriction (grants access to all environments) + newRule := vaultAccessRule{ + Application: appID, + Contexts: []string{SECRET_STORE_DEV_ALIAS}, + ID: len(vaultResp.Rules), + } + updatedRules := vaultResponse{Rules: append(vaultResp.Rules, newRule)} + body, err := json.Marshal(updatedRules) + if err != nil { + return err + } + + csrfToken, _ := t.csrfToken() + + // 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) + fmt.Printf("[vault DEBUG] PUT %s -> status=%d body=%s\n", vaultURL, putResp.StatusCode, putRawBody) + var putVaultResp vaultResponse + if err := json.Unmarshal(putRawBody, &putVaultResp); err != nil { + return fmt.Errorf("could not parse vault PUT response for %q: %w", vaultName, err) + } + + // Verify the new rule is present in response + for _, rule := range vaultResp.Rules { + if rule.Application == appID { + for _, ctx := range rule.Contexts { + if ctx == SECRET_STORE_DEV_ALIAS { + return nil + } + } + } + } + return fmt.Errorf("vault access rule for %q was not confirmed in response", vaultName) +} + +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 { + return nil + } + return ct.ensureVaultAccessForDev(vaultNames) +} diff --git a/client/go/internal/vespa/vault_test.go b/client/go/internal/vespa/vault_test.go new file mode 100644 index 000000000000..2fb0f4aeb69e --- /dev/null +++ b/client/go/internal/vespa/vault_test.go @@ -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) +} diff --git a/client/go/internal/vespa/xml/config.go b/client/go/internal/vespa/xml/config.go index 30b8b7e7d50e..fa60c0e6668a 100644 --- a/client/go/internal/vespa/xml/config.go +++ b/client/go/internal/vespa/xml/config.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "regexp" + "sort" "strconv" "strings" @@ -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 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 { diff --git a/client/go/internal/vespa/xml/config_test.go b/client/go/internal/vespa/xml/config_test.go index 494a2e2d8f1c..bbfe0063b8fc 100644 --- a/client/go/internal/vespa/xml/config_test.go +++ b/client/go/internal/vespa/xml/config_test.go @@ -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(``)) + 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(` + + + + + + +`)) + 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(` + + + + + + + + +`)) + 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 { From 9635f6f5833ec38538f7a8f42b0567c23523df2e Mon Sep 17 00:00:00 2001 From: Brage Date: Fri, 6 Mar 2026 13:46:50 +0100 Subject: [PATCH 2/3] fix: checking correct vault resp --- client/go/internal/vespa/vault.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/client/go/internal/vespa/vault.go b/client/go/internal/vespa/vault.go index 2bfcdeb11dc2..0f9d929290e3 100644 --- a/client/go/internal/vespa/vault.go +++ b/client/go/internal/vespa/vault.go @@ -119,14 +119,13 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { } defer putResp.Body.Close() putRawBody, _ := io.ReadAll(putResp.Body) - fmt.Printf("[vault DEBUG] PUT %s -> status=%d body=%s\n", vaultURL, putResp.StatusCode, putRawBody) var putVaultResp vaultResponse if err := json.Unmarshal(putRawBody, &putVaultResp); err != nil { return fmt.Errorf("could not parse vault PUT response for %q: %w", vaultName, err) } // Verify the new rule is present in response - for _, rule := range vaultResp.Rules { + for _, rule := range putVaultResp.Rules { if rule.Application == appID { for _, ctx := range rule.Contexts { if ctx == SECRET_STORE_DEV_ALIAS { From 5fe685a9a11f0fbaa0d50ec646132dcd11292168 Mon Sep 17 00:00:00 2001 From: Brage Date: Fri, 6 Mar 2026 14:17:10 +0100 Subject: [PATCH 3/3] fix: put response does not always give error any more --- client/go/internal/vespa/vault.go | 40 +++++++++++++++---------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/client/go/internal/vespa/vault.go b/client/go/internal/vespa/vault.go index 0f9d929290e3..df45aace6b0e 100644 --- a/client/go/internal/vespa/vault.go +++ b/client/go/internal/vespa/vault.go @@ -10,7 +10,7 @@ import ( "time" ) -const SECRET_STORE_DEV_ALIAS = "SANDBOX" +const secretStoreDevAlias = "SANDBOX" type vaultAccessRule struct { Application string `json:"application"` @@ -40,10 +40,14 @@ func (t *cloudTarget) csrfToken() (string, error) { 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.NewDecoder(resp.Body).Decode(&result); err != nil { + if err := json.Unmarshal(body, &result); err != nil { return "", err } return result.Token, nil @@ -70,6 +74,9 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { } 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) @@ -79,7 +86,7 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { for _, rule := range vaultResp.Rules { if rule.Application == appID { for _, ctx := range rule.Contexts { - if ctx == SECRET_STORE_DEV_ALIAS { + if ctx == secretStoreDevAlias { return nil } } @@ -89,7 +96,7 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { // Build new rule with no context restriction (grants access to all environments) newRule := vaultAccessRule{ Application: appID, - Contexts: []string{SECRET_STORE_DEV_ALIAS}, + Contexts: []string{secretStoreDevAlias}, ID: len(vaultResp.Rules), } updatedRules := vaultResponse{Rules: append(vaultResp.Rules, newRule)} @@ -98,7 +105,10 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { return err } - csrfToken, _ := t.csrfToken() + 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)) @@ -119,22 +129,10 @@ func (t *cloudTarget) ensureVaultAccessRule(vaultName string) error { } defer putResp.Body.Close() putRawBody, _ := io.ReadAll(putResp.Body) - var putVaultResp vaultResponse - if err := json.Unmarshal(putRawBody, &putVaultResp); err != nil { - return fmt.Errorf("could not parse vault PUT response for %q: %w", vaultName, err) + if putResp.StatusCode/100 != 2 { + return fmt.Errorf("could not set vault access rule for %q: server returned %d: %s", vaultName, putResp.StatusCode, putRawBody) } - - // Verify the new rule is present in response - for _, rule := range putVaultResp.Rules { - if rule.Application == appID { - for _, ctx := range rule.Contexts { - if ctx == SECRET_STORE_DEV_ALIAS { - return nil - } - } - } - } - return fmt.Errorf("vault access rule for %q was not confirmed in response", vaultName) + return nil } func (t *cloudTarget) ensureVaultAccessForDev(vaultNames []string) error { @@ -151,7 +149,7 @@ func (t *cloudTarget) ensureVaultAccessForDev(vaultNames []string) error { // Errors are non-fatal warnings for the caller. func EnsureVaultAccessForDev(target Target, vaultNames []string) error { ct, ok := target.(*cloudTarget) - if !ok || len(vaultNames) == 0 { + if !ok || len(vaultNames) == 0 || ct.deploymentOptions.Deployment.Zone.Environment != "dev" { return nil } return ct.ensureVaultAccessForDev(vaultNames)