Skip to content
Merged
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
4 changes: 4 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
version: "2"

run:
tests: false

linters:
default: none
enable:
Expand Down
23 changes: 20 additions & 3 deletions docker-compose.local.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
version: '3.8'

services:
example-app:
image: golang:1.26.2-alpine
ports:
- target: 8080
published: 8085
protocol: tcp
mode: host
volumes:
- ./:/app
working_dir: /app
command: go run ./examples/go-app/main.go
environment:
- MY_SECRET=/var/run/secrets/testik
secrets:
- source: test
target: /var/run/secrets/testik

cloud-secrets:
image: golang:1.26.2-alpine
ports:
Expand All @@ -16,8 +33,8 @@ services:
# - CLOUDRU_PROJECT_ID=<cloudru-project-id>
- CLOUDRU_IAM_CLIENT_ID=/var/run/secrets/iam_id
- CLOUDRU_IAM_CLIENT_SECRET=/var/run/secrets/iam_secret
- CLOUDRU_ROOT_FOLDER=/infra
- CLOUDRU_ROOT_FOLDER_OMIT_PREFIX=true
- CLOUDRU_ROOT_FOLDER=/
# - CLOUDRU_ROOT_FOLDER_OMIT_PREFIX=true
- CS_LOG_LEVEL=debug
- CS_REFRESH_INTERVAL=12s
- CS_CLEANUP_ORPHANED=true
Expand All @@ -34,7 +51,7 @@ services:

secrets:
# Секреты для тестового приложения
users-service-db-dsn:
test:
external: true
# Креды для IAM
iam_id:
Expand Down
1 change: 0 additions & 1 deletion internal/sync/payload.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ type SecretVersionRemoval struct {
type updatingServiceSecret struct {
Name string
ID string
Path string
}

func (p *syncPayload) hasPendingChanges() bool {
Expand Down
56 changes: 32 additions & 24 deletions internal/sync/step3_process_secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func (s *Synchronizer) createUpdatedSecretVersion(
payload.pendingServiceUpdates,
&payload.pendingServiceUpdateOrder,
payload.services,
swarmSecret.Path,
swarmSecret,
createdVersion,
)
payload.pendingSecretRestores = append(payload.pendingSecretRestores, UpdatedSecret{
Expand Down Expand Up @@ -156,7 +156,7 @@ func (s *Synchronizer) enqueueSameVersionServices(
) {
for _, service := range services {
for _, ref := range service.Spec.TaskTemplate.ContainerSpec.Secrets {
if ref.File.Name != swarmSecret.Path || ref.SecretID == swarmSecret.ID {
if !secretReferenceUsesManagedSecret(ref, swarmSecret) || ref.SecretID == swarmSecret.ID {
continue
}

Expand All @@ -171,10 +171,9 @@ func (s *Synchronizer) enqueueSameVersionServices(
*pendingServiceUpdateOrder = append(*pendingServiceUpdateOrder, task)
}

task.Secrets[swarmSecret.Path] = updatingServiceSecret{
task.Secrets[ref.SecretID] = updatingServiceSecret{
Name: swarmSecret.Path,
ID: swarmSecret.ID,
Path: swarmSecret.Path,
}
}
}
Expand All @@ -184,43 +183,52 @@ func (s *Synchronizer) enqueueUpdatedServices(
pendingServiceUpdates map[string]*ServiceTask,
pendingServiceUpdateOrder *[]*ServiceTask,
services []swarm.Service,
path string,
swarmSecret *engine.ExistingSecret,
secret engine.CreatedSecretVersion,
) {
for _, service := range services {
if !serviceUsesPath(service, path) {
continue
}

task, ok := pendingServiceUpdates[service.ID]
if !ok {
task = &ServiceTask{
Service: service,
Secrets: make(map[string]updatingServiceSecret),
task := pendingServiceUpdates[service.ID]
for _, ref := range service.Spec.TaskTemplate.ContainerSpec.Secrets {
if !secretReferenceUsesManagedSecret(ref, swarmSecret) {
continue
}

pendingServiceUpdates[service.ID] = task
*pendingServiceUpdateOrder = append(*pendingServiceUpdateOrder, task)
}
if task == nil {
task = &ServiceTask{
Service: service,
Secrets: make(map[string]updatingServiceSecret),
}

task.Secrets[path] = updatingServiceSecret{
Name: secret.Name,
ID: secret.ID,
Path: path,
pendingServiceUpdates[service.ID] = task
*pendingServiceUpdateOrder = append(*pendingServiceUpdateOrder, task)
}

task.Secrets[ref.SecretID] = updatingServiceSecret{
Name: secret.Name,
ID: secret.ID,
}
}
}
}

func serviceUsesPath(service swarm.Service, path string) bool {
for _, ref := range service.Spec.TaskTemplate.ContainerSpec.Secrets {
if ref.File.Name == path {
func secretReferenceUsesManagedSecret(ref *swarm.SecretReference, secret *engine.ExistingSecret) bool {
if secretReferenceUsesManagedSecretPath(ref, secret.Path) {
return true
}

for _, version := range secret.Versions {
if ref.SecretID == version.ID {
return true
}
}

return false
}

func secretReferenceUsesManagedSecretPath(ref *swarm.SecretReference, path string) bool {
return ref.SecretName == path
}

func (s *Synchronizer) prepareSecretPath(path string) string {
return strings.ReplaceAll(path, "/", string(s.folderDelimiter))
}
14 changes: 8 additions & 6 deletions internal/sync/step4_apply_service_updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"log/slog"

"github.com/moby/moby/api/types/swarm"
"github.com/swarm-deploy/cloud-secrets/internal/engine"
)

const stepApplyServices = "apply_service_updates"
Expand All @@ -21,15 +20,18 @@ func (s *Synchronizer) applyServiceUpdates(ctx context.Context, payload *syncPay
}

for _, service := range payload.pendingServiceUpdateOrder[payload.pendingServiceOffset:] {
secrets := []*swarm.SecretReference{}
secrets := make([]*swarm.SecretReference, 0, len(service.Service.Spec.TaskTemplate.ContainerSpec.Secrets))
for _, secRef := range service.Service.Spec.TaskTemplate.ContainerSpec.Secrets {
if _, ok := service.Secrets[secRef.File.Name]; !ok {
secret, ok := service.Secrets[secRef.SecretID]
if !ok {
secrets = append(secrets, secRef)
continue
}
}

for _, secret := range service.Secrets {
secrets = append(secrets, engine.NewSecretRef(secret.Path, secret.Name, secret.ID))
updatedRef := *secRef
updatedRef.SecretID = secret.ID
updatedRef.SecretName = secret.Name
secrets = append(secrets, &updatedRef)
}

service.Service.Spec.TaskTemplate.ContainerSpec.Secrets = secrets
Expand Down
174 changes: 174 additions & 0 deletions internal/sync/synchronizer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package sync

import (
"context"
"os"
"testing"

"github.com/moby/moby/api/types/swarm"
Expand Down Expand Up @@ -109,6 +110,159 @@ func TestSynchronizer_Sync(t *testing.T) {
},
want: Result{Updated: 1, RemovedSecrets: 1, RemovedSecretVersions: 1},
},

{
name: "update secret version preserves custom secret mount settings",
setup: func(engineClient *engine.MockClient, provider *contracts.MockProvider) {
existingSecret := engine.ExistingSecret{
ID: "parent-secret-id",
Path: "users-service-db-dsn",
ExternalPath: "users/service/db/dsn",
Managed: true,
Versions: []engine.ExistingSecretVersion{
{ID: "parent-secret-id", ExternalID: "version-1"},
},
}

originalRef := newCustomSecretRef(
"/app/config/db-password",
"1000",
"1000",
0400,
"users-service-db-dsn",
"parent-secret-id",
)
updatedRef := newCustomSecretRef(
"/app/config/db-password",
"1000",
"1000",
0400,
"users-service-db-dsn-version-2",
"new-version-secret-id",
)

engineClient.EXPECT().ListServices(gomock.Any()).Return([]swarm.Service{
newService("service-id", "api", originalRef),
}, nil)
engineClient.EXPECT().MapSecrets(gomock.Any()).Return(map[string]*engine.ExistingSecret{
"users-service-db-dsn": &existingSecret,
}, nil)
provider.EXPECT().ListSecrets(gomock.Any()).Return(map[string]contracts.Secret{
"users/service/db/dsn": {Path: "users/service/db/dsn", FullPath: "users/service/db/dsn", VersionID: "version-2"},
}, nil)
provider.EXPECT().GetSecretPayload(gomock.Any(), "users/service/db/dsn").Return([]byte("payload-2"), nil)
engineClient.EXPECT().CreateSecretVersion(gomock.Any(), existingSecret, engine.CreatingSecretVersion{
Path: "users-service-db-dsn-version-2", ExternalID: "version-2", Value: []byte("payload-2"),
}).Return(engine.CreatedSecretVersion{ID: "new-version-secret-id", Name: "users-service-db-dsn-version-2"}, nil)
engineClient.EXPECT().UpdateService(gomock.Any(), newService("service-id", "api", updatedRef)).Return(nil)
engineClient.EXPECT().RemoveSecret(gomock.Any(), "parent-secret-id").Return(nil)
engineClient.EXPECT().CreateSecret(gomock.Any(), engine.CreatingSecret{
Path: "users-service-db-dsn", Value: []byte("payload-2"), ExternalPath: "users/service/db/dsn", ExternalVersionID: "version-2",
}).Return(nil)
},
want: Result{Updated: 1, RemovedSecrets: 1},
},
{
name: "update secret version preserves duplicate secret references",
setup: func(engineClient *engine.MockClient, provider *contracts.MockProvider) {
existingSecret := engine.ExistingSecret{
ID: "test-secret-id",
Path: "test",
ExternalPath: "test",
Managed: true,
Versions: []engine.ExistingSecretVersion{
{ID: "test-secret-id", ExternalID: "version-1"},
},
}

originalRefA := newCustomSecretRef(
"/run/secrets/a",
"1000",
"1000",
0400,
"test",
"test-secret-id",
)
originalRefB := newCustomSecretRef(
"/run/secrets/b",
"2000",
"3000",
0440,
"test",
"test-secret-id",
)
updatedRefA := newCustomSecretRef(
"/run/secrets/a",
"1000",
"1000",
0400,
"test-version-2",
"new-version-secret-id",
)
updatedRefB := newCustomSecretRef(
"/run/secrets/b",
"2000",
"3000",
0440,
"test-version-2",
"new-version-secret-id",
)

engineClient.EXPECT().ListServices(gomock.Any()).Return([]swarm.Service{
newService("service-id", "api", originalRefA, originalRefB),
}, nil)
engineClient.EXPECT().MapSecrets(gomock.Any()).Return(map[string]*engine.ExistingSecret{
"test": &existingSecret,
}, nil)
provider.EXPECT().ListSecrets(gomock.Any()).Return(map[string]contracts.Secret{
"test": {Path: "test", FullPath: "test", VersionID: "version-2"},
}, nil)
provider.EXPECT().GetSecretPayload(gomock.Any(), "test").Return([]byte("payload-2"), nil)
engineClient.EXPECT().CreateSecretVersion(gomock.Any(), existingSecret, engine.CreatingSecretVersion{
Path: "test-version-2", ExternalID: "version-2", Value: []byte("payload-2"),
}).Return(engine.CreatedSecretVersion{ID: "new-version-secret-id", Name: "test-version-2"}, nil)
engineClient.EXPECT().UpdateService(
gomock.Any(),
newService("service-id", "api", updatedRefA, updatedRefB),
).Return(nil)
engineClient.EXPECT().RemoveSecret(gomock.Any(), "test-secret-id").Return(nil)
engineClient.EXPECT().CreateSecret(gomock.Any(), engine.CreatingSecret{
Path: "test", Value: []byte("payload-2"), ExternalPath: "test", ExternalVersionID: "version-2",
}).Return(nil)
},
want: Result{Updated: 1, RemovedSecrets: 1},
},
{
name: "restore parent secret preserves custom secret mount settings",
setup: func(engineClient *engine.MockClient, provider *contracts.MockProvider) {
existingSecret := engine.ExistingSecret{
ID: "parent-secret-id",
Path: "users-service-db-dsn",
ExternalPath: "users/service/db/dsn",
Managed: true,
Versions: []engine.ExistingSecretVersion{
{ID: "parent-secret-id", ExternalID: "version-2"},
{ID: "old-version-secret-id", ExternalID: "version-1"},
},
}

originalRef := newCustomSecretRef("/app/config/db-password", "1000", "1000", 0400, "users-service-db-dsn-version-1", "old-version-secret-id")
updatedRef := newCustomSecretRef("/app/config/db-password", "1000", "1000", 0400, "users-service-db-dsn", "parent-secret-id")

engineClient.EXPECT().ListServices(gomock.Any()).Return([]swarm.Service{
newService("service-id", "api", originalRef),
}, nil)
engineClient.EXPECT().MapSecrets(gomock.Any()).Return(map[string]*engine.ExistingSecret{
"users-service-db-dsn": &existingSecret,
}, nil)
provider.EXPECT().ListSecrets(gomock.Any()).Return(map[string]contracts.Secret{
"users/service/db/dsn": {Path: "users/service/db/dsn", FullPath: "users/service/db/dsn", VersionID: "version-2"},
}, nil)
engineClient.EXPECT().UpdateService(gomock.Any(), newService("service-id", "api", updatedRef)).Return(nil)
engineClient.EXPECT().RemoveSecret(gomock.Any(), "old-version-secret-id").Return(nil)
},
want: Result{Skipped: 1, RemovedSecretVersions: 1},
},
{
name: "remove old versions for managed secret on same version",
setup: func(engineClient *engine.MockClient, provider *contracts.MockProvider) {
Expand Down Expand Up @@ -502,6 +656,26 @@ func newService(id string, name string, secrets ...*swarm.SecretReference) swarm
}
}

func newCustomSecretRef(
fileName string,
uid string,
gid string,
mode os.FileMode,
secretName string,
id string,
) *swarm.SecretReference {
return &swarm.SecretReference{
File: &swarm.SecretReferenceFileTarget{
Name: fileName,
UID: uid,
GID: gid,
Mode: mode,
},
SecretName: secretName,
SecretID: id,
}
}

func newSecretRef(fileName string, secretName string, id string) *swarm.SecretReference {
return &swarm.SecretReference{
File: &swarm.SecretReferenceFileTarget{
Expand Down
Loading