From 17ee2e8d53eda150b41320d02667edde32bb21c2 Mon Sep 17 00:00:00 2001 From: alexma233 Date: Sat, 1 Aug 2026 11:37:52 +0800 Subject: [PATCH] unify encrypted repository files --- .gitignore | 3 + .../v1/controllerv1connect/repo.connect.go | 8 +- .../v1/controllerv1connect/secret.connect.go | 141 -------- .../proto/composia/controller/v1/repo.pb.go | 8 +- .../proto/composia/controller/v1/secret.pb.go | 315 ------------------ internal/app/agent/backup_restore_tasks.go | 16 +- .../app/agent/backup_restore_tasks_test.go | 12 + internal/app/agent/bundle_test.go | 2 +- internal/app/agent/caddy_tasks.go | 2 +- internal/app/agent/compose_runtime.go | 3 + internal/app/agent/image_update.go | 5 +- internal/app/agent/image_update_test.go | 4 +- internal/app/agent/task_exec_test.go | 12 + internal/app/cli/cli.go | 9 +- internal/app/cli/cli_test.go | 7 +- internal/app/cli/cobra.go | 5 - internal/app/cli/command_secret.go | 100 ------ internal/app/controller/bootstrap.go | 6 - internal/app/controller/bundle.go | 3 + internal/app/controller/bundle_runtime.go | 53 ++- internal/app/controller/bundle_test.go | 79 ++++- internal/app/controller/repo_command.go | 53 ++- internal/app/controller/repo_encryption.go | 49 +++ internal/app/controller/repo_query.go | 25 +- internal/app/controller/repo_test.go | 19 ++ internal/app/controller/secret.go | 120 ------- internal/app/controller/secret_test.go | 70 ++-- internal/app/controller/service_command.go | 12 +- internal/app/controller/service_test.go | 18 +- internal/core/repo/bundle.go | 41 ++- internal/core/repo/bundle_test.go | 31 ++ internal/core/repo/data_include.go | 3 + internal/core/repo/encrypted_file.go | 79 +++++ internal/core/repo/encrypted_file_test.go | 39 +++ internal/core/repo/files.go | 146 +++++++- internal/core/repo/files_test.go | 32 ++ internal/core/repo/git.go | 16 + internal/core/repo/git_test.go | 22 ++ internal/core/repo/services.go | 15 + internal/core/repo/services_test.go | 11 + proto/composia/controller/v1/repo.proto | 12 +- proto/composia/controller/v1/secret.proto | 43 --- .../api/controller-reference.md | 100 +----- site/content/docs/guide/image-updates.md | 2 +- site/content/docs/guide/secrets.de.md | 16 +- site/content/docs/guide/secrets.fr.md | 16 +- site/content/docs/guide/secrets.ja.md | 16 +- site/content/docs/guide/secrets.md | 39 ++- site/content/docs/guide/secrets.zh-hans.md | 16 +- site/content/docs/guide/secrets.zh-hant.md | 16 +- site/content/docs/guide/service.md | 2 +- test/controller_e2e/controller_test.go | 39 +-- .../gen/proto/composia/agent/v1/agent_pb.ts | 2 +- .../proto/composia/controller/v1/backup_pb.ts | 2 +- .../composia/controller/v1/container_pb.ts | 2 +- .../proto/composia/controller/v1/node_pb.ts | 2 +- .../proto/composia/controller/v1/repo_pb.ts | 14 +- .../proto/composia/controller/v1/secret_pb.ts | 155 --------- .../composia/controller/v1/service_pb.ts | 2 +- .../proto/composia/controller/v1/system_pb.ts | 2 +- .../proto/composia/controller/v1/task_pb.ts | 2 +- web/src/lib/server/controller.ts | 35 -- web/src/lib/server/service-workspace.ts | 29 +- web/src/lib/service-workspace.ts | 2 +- 64 files changed, 926 insertions(+), 1234 deletions(-) delete mode 100644 gen/go/proto/composia/controller/v1/controllerv1connect/secret.connect.go delete mode 100644 gen/go/proto/composia/controller/v1/secret.pb.go delete mode 100644 internal/app/cli/command_secret.go create mode 100644 internal/app/controller/repo_encryption.go delete mode 100644 internal/app/controller/secret.go create mode 100644 internal/core/repo/encrypted_file.go create mode 100644 internal/core/repo/encrypted_file_test.go delete mode 100644 proto/composia/controller/v1/secret.proto delete mode 100644 web/src/lib/gen/proto/composia/controller/v1/secret_pb.ts diff --git a/.gitignore b/.gitignore index e119e2f..2c8e53f 100644 --- a/.gitignore +++ b/.gitignore @@ -224,3 +224,6 @@ sources/ statuses/ sessions/ .claude-plugin/ + +# PI sub-agents +.pi-subagents/ diff --git a/gen/go/proto/composia/controller/v1/controllerv1connect/repo.connect.go b/gen/go/proto/composia/controller/v1/controllerv1connect/repo.connect.go index f5ca765..dea411e 100644 --- a/gen/go/proto/composia/controller/v1/controllerv1connect/repo.connect.go +++ b/gen/go/proto/composia/controller/v1/controllerv1connect/repo.connect.go @@ -73,7 +73,7 @@ type RepoQueryServiceClient interface { GetRepoHead(context.Context, *connect.Request[v1.GetRepoHeadRequest]) (*connect.Response[v1.GetRepoHeadResponse], error) // ListRepoFiles lists direct children for one repo path. ListRepoFiles(context.Context, *connect.Request[v1.ListRepoFilesRequest]) (*connect.Response[v1.ListRepoFilesResponse], error) - // GetRepoFile returns the content of one repo file. + // GetRepoFile returns plaintext for .enc files and stored content for other files. GetRepoFile(context.Context, *connect.Request[v1.GetRepoFileRequest]) (*connect.Response[v1.GetRepoFileResponse], error) // ListRepoCommits returns commit history using cursor pagination. ListRepoCommits(context.Context, *connect.Request[v1.ListRepoCommitsRequest]) (*connect.Response[v1.ListRepoCommitsResponse], error) @@ -166,7 +166,7 @@ type RepoQueryServiceHandler interface { GetRepoHead(context.Context, *connect.Request[v1.GetRepoHeadRequest]) (*connect.Response[v1.GetRepoHeadResponse], error) // ListRepoFiles lists direct children for one repo path. ListRepoFiles(context.Context, *connect.Request[v1.ListRepoFilesRequest]) (*connect.Response[v1.ListRepoFilesResponse], error) - // GetRepoFile returns the content of one repo file. + // GetRepoFile returns plaintext for .enc files and stored content for other files. GetRepoFile(context.Context, *connect.Request[v1.GetRepoFileRequest]) (*connect.Response[v1.GetRepoFileResponse], error) // ListRepoCommits returns commit history using cursor pagination. ListRepoCommits(context.Context, *connect.Request[v1.ListRepoCommitsRequest]) (*connect.Response[v1.ListRepoCommitsResponse], error) @@ -254,7 +254,7 @@ func (UnimplementedRepoQueryServiceHandler) ValidateRepo(context.Context, *conne // RepoCommandServiceClient is a client for the composia.controller.v1.RepoCommandService service. type RepoCommandServiceClient interface { - // UpdateRepoFile writes one file, creates a commit, and reports sync state. + // UpdateRepoFile encrypts plaintext for .enc files, writes one file, creates a commit, and reports sync state. UpdateRepoFile(context.Context, *connect.Request[v1.UpdateRepoFileRequest]) (*connect.Response[v1.RepoWriteResult], error) // CreateRepoDirectory creates one directory, creates a commit, and reports sync state. CreateRepoDirectory(context.Context, *connect.Request[v1.CreateRepoDirectoryRequest]) (*connect.Response[v1.RepoWriteResult], error) @@ -347,7 +347,7 @@ func (c *repoCommandServiceClient) SyncRepo(ctx context.Context, req *connect.Re // RepoCommandServiceHandler is an implementation of the composia.controller.v1.RepoCommandService // service. type RepoCommandServiceHandler interface { - // UpdateRepoFile writes one file, creates a commit, and reports sync state. + // UpdateRepoFile encrypts plaintext for .enc files, writes one file, creates a commit, and reports sync state. UpdateRepoFile(context.Context, *connect.Request[v1.UpdateRepoFileRequest]) (*connect.Response[v1.RepoWriteResult], error) // CreateRepoDirectory creates one directory, creates a commit, and reports sync state. CreateRepoDirectory(context.Context, *connect.Request[v1.CreateRepoDirectoryRequest]) (*connect.Response[v1.RepoWriteResult], error) diff --git a/gen/go/proto/composia/controller/v1/controllerv1connect/secret.connect.go b/gen/go/proto/composia/controller/v1/controllerv1connect/secret.connect.go deleted file mode 100644 index 888f250..0000000 --- a/gen/go/proto/composia/controller/v1/controllerv1connect/secret.connect.go +++ /dev/null @@ -1,141 +0,0 @@ -// Code generated by protoc-gen-connect-go. DO NOT EDIT. -// -// Source: proto/composia/controller/v1/secret.proto - -package controllerv1connect - -import ( - connect "connectrpc.com/connect" - context "context" - errors "errors" - v1 "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/controller/v1" - http "net/http" - strings "strings" -) - -// This is a compile-time assertion to ensure that this generated file and the connect package are -// compatible. If you get a compiler error that this constant is not defined, this code was -// generated with a version of connect newer than the one compiled into your binary. You can fix the -// problem by either regenerating this code with an older version of connect or updating the connect -// version compiled into your binary. -const _ = connect.IsAtLeastVersion1_13_0 - -const ( - // SecretServiceName is the fully-qualified name of the SecretService service. - SecretServiceName = "composia.controller.v1.SecretService" -) - -// These constants are the fully-qualified names of the RPCs defined in this package. They're -// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. -// -// Note that these are different from the fully-qualified method names used by -// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to -// reflection-formatted method names, remove the leading slash and convert the remaining slash to a -// period. -const ( - // SecretServiceGetSecretProcedure is the fully-qualified name of the SecretService's GetSecret RPC. - SecretServiceGetSecretProcedure = "/composia.controller.v1.SecretService/GetSecret" - // SecretServiceUpdateSecretProcedure is the fully-qualified name of the SecretService's - // UpdateSecret RPC. - SecretServiceUpdateSecretProcedure = "/composia.controller.v1.SecretService/UpdateSecret" -) - -// SecretServiceClient is a client for the composia.controller.v1.SecretService service. -type SecretServiceClient interface { - // GetSecret returns the decrypted content for one service secret file. - GetSecret(context.Context, *connect.Request[v1.GetSecretRequest]) (*connect.Response[v1.GetSecretResponse], error) - // UpdateSecret writes one secret file and reports the resulting repo sync state. - UpdateSecret(context.Context, *connect.Request[v1.UpdateSecretRequest]) (*connect.Response[v1.RepoWriteResult], error) -} - -// NewSecretServiceClient constructs a client for the composia.controller.v1.SecretService service. -// By default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped -// responses, and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the -// connect.WithGRPC() or connect.WithGRPCWeb() options. -// -// The URL supplied here should be the base URL for the Connect or gRPC server (for example, -// http://api.acme.com or https://acme.com/grpc). -func NewSecretServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) SecretServiceClient { - baseURL = strings.TrimRight(baseURL, "/") - secretServiceMethods := v1.File_proto_composia_controller_v1_secret_proto.Services().ByName("SecretService").Methods() - return &secretServiceClient{ - getSecret: connect.NewClient[v1.GetSecretRequest, v1.GetSecretResponse]( - httpClient, - baseURL+SecretServiceGetSecretProcedure, - connect.WithSchema(secretServiceMethods.ByName("GetSecret")), - connect.WithClientOptions(opts...), - ), - updateSecret: connect.NewClient[v1.UpdateSecretRequest, v1.RepoWriteResult]( - httpClient, - baseURL+SecretServiceUpdateSecretProcedure, - connect.WithSchema(secretServiceMethods.ByName("UpdateSecret")), - connect.WithClientOptions(opts...), - ), - } -} - -// secretServiceClient implements SecretServiceClient. -type secretServiceClient struct { - getSecret *connect.Client[v1.GetSecretRequest, v1.GetSecretResponse] - updateSecret *connect.Client[v1.UpdateSecretRequest, v1.RepoWriteResult] -} - -// GetSecret calls composia.controller.v1.SecretService.GetSecret. -func (c *secretServiceClient) GetSecret(ctx context.Context, req *connect.Request[v1.GetSecretRequest]) (*connect.Response[v1.GetSecretResponse], error) { - return c.getSecret.CallUnary(ctx, req) -} - -// UpdateSecret calls composia.controller.v1.SecretService.UpdateSecret. -func (c *secretServiceClient) UpdateSecret(ctx context.Context, req *connect.Request[v1.UpdateSecretRequest]) (*connect.Response[v1.RepoWriteResult], error) { - return c.updateSecret.CallUnary(ctx, req) -} - -// SecretServiceHandler is an implementation of the composia.controller.v1.SecretService service. -type SecretServiceHandler interface { - // GetSecret returns the decrypted content for one service secret file. - GetSecret(context.Context, *connect.Request[v1.GetSecretRequest]) (*connect.Response[v1.GetSecretResponse], error) - // UpdateSecret writes one secret file and reports the resulting repo sync state. - UpdateSecret(context.Context, *connect.Request[v1.UpdateSecretRequest]) (*connect.Response[v1.RepoWriteResult], error) -} - -// NewSecretServiceHandler builds an HTTP handler from the service implementation. It returns the -// path on which to mount the handler and the handler itself. -// -// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf -// and JSON codecs. They also support gzip compression. -func NewSecretServiceHandler(svc SecretServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { - secretServiceMethods := v1.File_proto_composia_controller_v1_secret_proto.Services().ByName("SecretService").Methods() - secretServiceGetSecretHandler := connect.NewUnaryHandler( - SecretServiceGetSecretProcedure, - svc.GetSecret, - connect.WithSchema(secretServiceMethods.ByName("GetSecret")), - connect.WithHandlerOptions(opts...), - ) - secretServiceUpdateSecretHandler := connect.NewUnaryHandler( - SecretServiceUpdateSecretProcedure, - svc.UpdateSecret, - connect.WithSchema(secretServiceMethods.ByName("UpdateSecret")), - connect.WithHandlerOptions(opts...), - ) - return "/composia.controller.v1.SecretService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.URL.Path { - case SecretServiceGetSecretProcedure: - secretServiceGetSecretHandler.ServeHTTP(w, r) - case SecretServiceUpdateSecretProcedure: - secretServiceUpdateSecretHandler.ServeHTTP(w, r) - default: - http.NotFound(w, r) - } - }) -} - -// UnimplementedSecretServiceHandler returns CodeUnimplemented from all methods. -type UnimplementedSecretServiceHandler struct{} - -func (UnimplementedSecretServiceHandler) GetSecret(context.Context, *connect.Request[v1.GetSecretRequest]) (*connect.Response[v1.GetSecretResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("composia.controller.v1.SecretService.GetSecret is not implemented")) -} - -func (UnimplementedSecretServiceHandler) UpdateSecret(context.Context, *connect.Request[v1.UpdateSecretRequest]) (*connect.Response[v1.RepoWriteResult], error) { - return nil, connect.NewError(connect.CodeUnimplemented, errors.New("composia.controller.v1.SecretService.UpdateSecret is not implemented")) -} diff --git a/gen/go/proto/composia/controller/v1/repo.pb.go b/gen/go/proto/composia/controller/v1/repo.pb.go index 8095c29..7871160 100644 --- a/gen/go/proto/composia/controller/v1/repo.pb.go +++ b/gen/go/proto/composia/controller/v1/repo.pb.go @@ -374,14 +374,14 @@ func (x *GetRepoFileRequest) GetPath() string { return "" } -// GetRepoFileResponse returns a repo file and its text content. +// GetRepoFileResponse returns a repo file and its API-visible text content. type GetRepoFileResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // path is the repo-relative path that was read. Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - // content is the file content as text. + // content is decrypted plaintext for .enc files and stored text for other files. Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` - // size is the file size in bytes. + // size is the byte length of content. ListRepoFiles reports stored sizes instead. Size int64 `protobuf:"varint,3,opt,name=size,proto3" json:"size,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -761,7 +761,7 @@ type UpdateRepoFileRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // path is the repo-relative path to update. Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - // content is the full replacement file content. + // content is plaintext for .enc files and stored text for other files. Content string `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` // base_revision protects against writing on top of an unexpected HEAD. BaseRevision string `protobuf:"bytes,3,opt,name=base_revision,json=baseRevision,proto3" json:"base_revision,omitempty"` diff --git a/gen/go/proto/composia/controller/v1/secret.pb.go b/gen/go/proto/composia/controller/v1/secret.pb.go deleted file mode 100644 index 3d1a4c0..0000000 --- a/gen/go/proto/composia/controller/v1/secret.pb.go +++ /dev/null @@ -1,315 +0,0 @@ -// Code generated by protoc-gen-go. DO NOT EDIT. -// versions: -// protoc-gen-go v1.36.11 -// protoc (unknown) -// source: proto/composia/controller/v1/secret.proto - -package controllerv1 - -import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" - reflect "reflect" - sync "sync" - unsafe "unsafe" -) - -const ( - // Verify that this generated code is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) - // Verify that runtime/protoimpl is sufficiently up-to-date. - _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) -) - -// GetSecretRequest identifies one decrypted secret file for a service. -type GetSecretRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"` - ServiceDir string `protobuf:"bytes,3,opt,name=service_dir,json=serviceDir,proto3" json:"service_dir,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetSecretRequest) Reset() { - *x = GetSecretRequest{} - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[0] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetSecretRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSecretRequest) ProtoMessage() {} - -func (x *GetSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[0] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetSecretRequest.ProtoReflect.Descriptor instead. -func (*GetSecretRequest) Descriptor() ([]byte, []int) { - return file_proto_composia_controller_v1_secret_proto_rawDescGZIP(), []int{0} -} - -func (x *GetSecretRequest) GetServiceName() string { - if x != nil { - return x.ServiceName - } - return "" -} - -func (x *GetSecretRequest) GetFilePath() string { - if x != nil { - return x.FilePath - } - return "" -} - -func (x *GetSecretRequest) GetServiceDir() string { - if x != nil { - return x.ServiceDir - } - return "" -} - -// GetSecretResponse returns one decrypted secret file. -type GetSecretResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"` - Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *GetSecretResponse) Reset() { - *x = GetSecretResponse{} - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[1] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *GetSecretResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*GetSecretResponse) ProtoMessage() {} - -func (x *GetSecretResponse) ProtoReflect() protoreflect.Message { - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[1] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use GetSecretResponse.ProtoReflect.Descriptor instead. -func (*GetSecretResponse) Descriptor() ([]byte, []int) { - return file_proto_composia_controller_v1_secret_proto_rawDescGZIP(), []int{1} -} - -func (x *GetSecretResponse) GetServiceName() string { - if x != nil { - return x.ServiceName - } - return "" -} - -func (x *GetSecretResponse) GetFilePath() string { - if x != nil { - return x.FilePath - } - return "" -} - -func (x *GetSecretResponse) GetContent() string { - if x != nil { - return x.Content - } - return "" -} - -// UpdateSecretRequest writes one decrypted secret file for a service. -type UpdateSecretRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"` - // file_path is the repo-relative secret file path for the service. - FilePath string `protobuf:"bytes,2,opt,name=file_path,json=filePath,proto3" json:"file_path,omitempty"` - // content is the full decrypted secret file content to store. - Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` - // base_revision protects against writing on top of an unexpected HEAD. - BaseRevision string `protobuf:"bytes,4,opt,name=base_revision,json=baseRevision,proto3" json:"base_revision,omitempty"` - // commit_message is used for the generated Git commit. - CommitMessage string `protobuf:"bytes,5,opt,name=commit_message,json=commitMessage,proto3" json:"commit_message,omitempty"` - ServiceDir string `protobuf:"bytes,6,opt,name=service_dir,json=serviceDir,proto3" json:"service_dir,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *UpdateSecretRequest) Reset() { - *x = UpdateSecretRequest{} - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[2] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *UpdateSecretRequest) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*UpdateSecretRequest) ProtoMessage() {} - -func (x *UpdateSecretRequest) ProtoReflect() protoreflect.Message { - mi := &file_proto_composia_controller_v1_secret_proto_msgTypes[2] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use UpdateSecretRequest.ProtoReflect.Descriptor instead. -func (*UpdateSecretRequest) Descriptor() ([]byte, []int) { - return file_proto_composia_controller_v1_secret_proto_rawDescGZIP(), []int{2} -} - -func (x *UpdateSecretRequest) GetServiceName() string { - if x != nil { - return x.ServiceName - } - return "" -} - -func (x *UpdateSecretRequest) GetFilePath() string { - if x != nil { - return x.FilePath - } - return "" -} - -func (x *UpdateSecretRequest) GetContent() string { - if x != nil { - return x.Content - } - return "" -} - -func (x *UpdateSecretRequest) GetBaseRevision() string { - if x != nil { - return x.BaseRevision - } - return "" -} - -func (x *UpdateSecretRequest) GetCommitMessage() string { - if x != nil { - return x.CommitMessage - } - return "" -} - -func (x *UpdateSecretRequest) GetServiceDir() string { - if x != nil { - return x.ServiceDir - } - return "" -} - -var File_proto_composia_controller_v1_secret_proto protoreflect.FileDescriptor - -const file_proto_composia_controller_v1_secret_proto_rawDesc = "" + - "\n" + - ")proto/composia/controller/v1/secret.proto\x12\x16composia.controller.v1\x1a'proto/composia/controller/v1/repo.proto\"s\n" + - "\x10GetSecretRequest\x12!\n" + - "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1b\n" + - "\tfile_path\x18\x02 \x01(\tR\bfilePath\x12\x1f\n" + - "\vservice_dir\x18\x03 \x01(\tR\n" + - "serviceDir\"m\n" + - "\x11GetSecretResponse\x12!\n" + - "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1b\n" + - "\tfile_path\x18\x02 \x01(\tR\bfilePath\x12\x18\n" + - "\acontent\x18\x03 \x01(\tR\acontent\"\xdc\x01\n" + - "\x13UpdateSecretRequest\x12!\n" + - "\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1b\n" + - "\tfile_path\x18\x02 \x01(\tR\bfilePath\x12\x18\n" + - "\acontent\x18\x03 \x01(\tR\acontent\x12#\n" + - "\rbase_revision\x18\x04 \x01(\tR\fbaseRevision\x12%\n" + - "\x0ecommit_message\x18\x05 \x01(\tR\rcommitMessage\x12\x1f\n" + - "\vservice_dir\x18\x06 \x01(\tR\n" + - "serviceDir2\xd7\x01\n" + - "\rSecretService\x12`\n" + - "\tGetSecret\x12(.composia.controller.v1.GetSecretRequest\x1a).composia.controller.v1.GetSecretResponse\x12d\n" + - "\fUpdateSecret\x12+.composia.controller.v1.UpdateSecretRequest\x1a'.composia.controller.v1.RepoWriteResultBXZVforgejo.alexma.top/alexma233/composia/gen/go/proto/composia/controller/v1;controllerv1b\x06proto3" - -var ( - file_proto_composia_controller_v1_secret_proto_rawDescOnce sync.Once - file_proto_composia_controller_v1_secret_proto_rawDescData []byte -) - -func file_proto_composia_controller_v1_secret_proto_rawDescGZIP() []byte { - file_proto_composia_controller_v1_secret_proto_rawDescOnce.Do(func() { - file_proto_composia_controller_v1_secret_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_composia_controller_v1_secret_proto_rawDesc), len(file_proto_composia_controller_v1_secret_proto_rawDesc))) - }) - return file_proto_composia_controller_v1_secret_proto_rawDescData -} - -var file_proto_composia_controller_v1_secret_proto_msgTypes = make([]protoimpl.MessageInfo, 3) -var file_proto_composia_controller_v1_secret_proto_goTypes = []any{ - (*GetSecretRequest)(nil), // 0: composia.controller.v1.GetSecretRequest - (*GetSecretResponse)(nil), // 1: composia.controller.v1.GetSecretResponse - (*UpdateSecretRequest)(nil), // 2: composia.controller.v1.UpdateSecretRequest - (*RepoWriteResult)(nil), // 3: composia.controller.v1.RepoWriteResult -} -var file_proto_composia_controller_v1_secret_proto_depIdxs = []int32{ - 0, // 0: composia.controller.v1.SecretService.GetSecret:input_type -> composia.controller.v1.GetSecretRequest - 2, // 1: composia.controller.v1.SecretService.UpdateSecret:input_type -> composia.controller.v1.UpdateSecretRequest - 1, // 2: composia.controller.v1.SecretService.GetSecret:output_type -> composia.controller.v1.GetSecretResponse - 3, // 3: composia.controller.v1.SecretService.UpdateSecret:output_type -> composia.controller.v1.RepoWriteResult - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name -} - -func init() { file_proto_composia_controller_v1_secret_proto_init() } -func file_proto_composia_controller_v1_secret_proto_init() { - if File_proto_composia_controller_v1_secret_proto != nil { - return - } - file_proto_composia_controller_v1_repo_proto_init() - type x struct{} - out := protoimpl.TypeBuilder{ - File: protoimpl.DescBuilder{ - GoPackagePath: reflect.TypeOf(x{}).PkgPath(), - RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_composia_controller_v1_secret_proto_rawDesc), len(file_proto_composia_controller_v1_secret_proto_rawDesc)), - NumEnums: 0, - NumMessages: 3, - NumExtensions: 0, - NumServices: 1, - }, - GoTypes: file_proto_composia_controller_v1_secret_proto_goTypes, - DependencyIndexes: file_proto_composia_controller_v1_secret_proto_depIdxs, - MessageInfos: file_proto_composia_controller_v1_secret_proto_msgTypes, - }.Build() - File_proto_composia_controller_v1_secret_proto = out.File - file_proto_composia_controller_v1_secret_proto_goTypes = nil - file_proto_composia_controller_v1_secret_proto_depIdxs = nil -} diff --git a/internal/app/agent/backup_restore_tasks.go b/internal/app/agent/backup_restore_tasks.go index 7eb82d9..d23c835 100644 --- a/internal/app/agent/backup_restore_tasks.go +++ b/internal/app/agent/backup_restore_tasks.go @@ -292,7 +292,7 @@ func prepareRestoreVolumeFlags(ctx context.Context, serviceRoot, stagingDir, con var flags []string for _, include := range item.Include { - kind, normalized, err := repo.ClassifyDataInclude(include) + kind, normalized, err := runtimeDataInclude(include) if err != nil { return nil, err } @@ -327,7 +327,7 @@ func prepareRestoreVolumeFlags(ctx context.Context, serviceRoot, stagingDir, con return nil, fmt.Errorf("close restore target file %q: %w", targetPath, err) } } - containerPath := filepath.Join(containerStagingDir, item.Name, "paths", sanitizeStagePath(include)) + containerPath := filepath.Join(containerStagingDir, item.Name, "paths", sanitizeStagePath(normalized)) flags = append(flags, "-v", targetPath+":"+containerPath) } else { if err := clearDockerVolume(ctx, normalized); err != nil { @@ -387,7 +387,7 @@ func resolveIncludeServicePath(serviceRoot, includePath string) (string, error) func buildBackupVolumeFlags(serviceRoot, containerStagingDir string, item backupcfg.RuntimeItem) ([]string, error) { var flags []string for _, include := range item.Include { - kind, normalized, err := repo.ClassifyDataInclude(include) + kind, normalized, err := runtimeDataInclude(include) if err != nil { return nil, err } @@ -402,7 +402,7 @@ func buildBackupVolumeFlags(serviceRoot, containerStagingDir string, item backup } return nil, fmt.Errorf("stat backup source %q: %w", sourcePath, err) } - targetPath := filepath.Join(containerStagingDir, "paths", sanitizeStagePath(include)) + targetPath := filepath.Join(containerStagingDir, "paths", sanitizeStagePath(normalized)) flags = append(flags, "-v", sourcePath+":"+targetPath+":ro") } else { targetPath := filepath.Join(containerStagingDir, "volumes", sanitizeStagePath(normalized)) @@ -416,3 +416,11 @@ func sanitizeStagePath(value string) string { replacer := strings.NewReplacer("/", "_", `\\`, "_", ":", "_") return replacer.Replace(strings.TrimPrefix(strings.TrimPrefix(value, "./"), "/")) } + +func runtimeDataInclude(include string) (repo.DataIncludeKind, string, error) { + kind, normalized, err := repo.ClassifyDataInclude(include) + if err == nil && kind == repo.DataIncludeKindServicePath { + normalized = repo.RuntimeFilePath(normalized) + } + return kind, normalized, err +} diff --git a/internal/app/agent/backup_restore_tasks_test.go b/internal/app/agent/backup_restore_tasks_test.go index d70b458..28440c9 100644 --- a/internal/app/agent/backup_restore_tasks_test.go +++ b/internal/app/agent/backup_restore_tasks_test.go @@ -15,6 +15,7 @@ import ( "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/agent/v1/agentv1connect" backupcfg "forgejo.alexma.top/alexma233/composia/internal/core/backup" "forgejo.alexma.top/alexma233/composia/internal/core/config" + "forgejo.alexma.top/alexma233/composia/internal/core/repo" "forgejo.alexma.top/alexma233/composia/internal/core/task" "forgejo.alexma.top/alexma233/composia/internal/platform/rpcutil" ) @@ -165,6 +166,17 @@ func TestStageBackupItemRunsPGDumpAll(t *testing.T) { } } +func TestRuntimeDataIncludeOnlyStripsServicePaths(t *testing.T) { + kind, path, err := runtimeDataInclude("./config.env.enc") + if err != nil || kind != repo.DataIncludeKindServicePath || path != "config.env" { + t.Fatalf("service include = %q/%q/%v", kind, path, err) + } + kind, path, err = runtimeDataInclude("config.env.enc") + if err != nil || kind != repo.DataIncludeKindDockerVolume || path != "config.env.enc" { + t.Fatalf("volume include = %q/%q/%v", kind, path, err) + } +} + func TestApplyRestoreItemRejectsUnknownStrategy(t *testing.T) { t.Parallel() diff --git a/internal/app/agent/bundle_test.go b/internal/app/agent/bundle_test.go index ef7e1e1..bbae66e 100644 --- a/internal/app/agent/bundle_test.go +++ b/internal/app/agent/bundle_test.go @@ -630,7 +630,7 @@ func TestLoadComposeCommandConfigLoadsComposeFiles(t *testing.T) { if err := os.MkdirAll(serviceDir, 0o750); err != nil { t.Fatalf("create service dir: %v", err) } - meta := "name: demo\nproject_name: demo-stack\ncompose_files:\n - compose.yaml\n - compose.prod.yaml\nnodes:\n - main\n" + meta := "name: demo\nproject_name: demo-stack\ncompose_files:\n - compose.yaml.enc\n - compose.prod.yaml\nnodes:\n - main\n" if err := os.WriteFile(filepath.Join(serviceDir, "composia-meta.yaml"), []byte(meta), 0o600); err != nil { t.Fatalf("write service meta: %v", err) } diff --git a/internal/app/agent/caddy_tasks.go b/internal/app/agent/caddy_tasks.go index d7f90d1..47d6735 100644 --- a/internal/app/agent/caddy_tasks.go +++ b/internal/app/agent/caddy_tasks.go @@ -230,7 +230,7 @@ func caddyGeneratedFileName(serviceDir string) (string, error) { } func resolveServiceCaddySourcePath(serviceRoot, source string) (string, error) { - cleanSource := filepath.Clean(strings.TrimSpace(source)) + cleanSource := filepath.Clean(repo.RuntimeFilePath(strings.TrimSpace(source))) if cleanSource == "." || cleanSource == "" { return "", errors.New("network.caddy.source must not be empty") } diff --git a/internal/app/agent/compose_runtime.go b/internal/app/agent/compose_runtime.go index 58461cd..f471dfc 100644 --- a/internal/app/agent/compose_runtime.go +++ b/internal/app/agent/compose_runtime.go @@ -152,5 +152,8 @@ func loadComposeCommandConfig(serviceDir, fallback string) (composeCommandConfig if err != nil { return composeCommandConfig{}, repo.ServiceMeta{}, fmt.Errorf("service meta %q: %w", metaPath, err) } + for index := range composeFiles { + composeFiles[index] = repo.RuntimeFilePath(composeFiles[index]) + } return composeCommandConfig{ProjectName: repo.ComposeProjectName(meta.ProjectName, fallback), Files: composeFiles}, meta, nil } diff --git a/internal/app/agent/image_update.go b/internal/app/agent/image_update.go index ac97789..30de7cd 100644 --- a/internal/app/agent/image_update.go +++ b/internal/app/agent/image_update.go @@ -206,10 +206,11 @@ func currentImageUpdateValue(serviceDir string, image repo.ImageUpdateConfig) (s } else { currentFile = image.Current.YAML.File } - path := filepath.Join(serviceDir, filepath.FromSlash(currentFile)) + runtimeFile := repo.RuntimeFilePath(currentFile) + path := filepath.Join(serviceDir, filepath.FromSlash(runtimeFile)) content, err := os.ReadFile(path) //nolint:gosec if err != nil { - return "", "", "", fmt.Errorf("read image update current %q: %w", currentFile, err) + return "", "", "", fmt.Errorf("read image update current %q: %w", runtimeFile, err) } if image.Current.Env != nil { value, err := envFileValue(string(content), image.Current.Env.Key) diff --git a/internal/app/agent/image_update_test.go b/internal/app/agent/image_update_test.go index 880f7bf..456bd9d 100644 --- a/internal/app/agent/image_update_test.go +++ b/internal/app/agent/image_update_test.go @@ -14,7 +14,7 @@ func TestCurrentImageUpdateValueFromEnvFile(t *testing.T) { serviceDir := t.TempDir() writeAgentTestFile(t, filepath.Join(serviceDir, ".env"), "# comment\nAPP_IMAGE='1.2.3@sha256:abc'\n") - value, tag, digest, err := currentImageUpdateValue(serviceDir, repo.ImageUpdateConfig{Current: repo.ImageUpdateCurrent{Env: &repo.ImageUpdateCurrentEnv{File: ".env", Key: "APP_IMAGE"}}}) + value, tag, digest, err := currentImageUpdateValue(serviceDir, repo.ImageUpdateConfig{Current: repo.ImageUpdateCurrent{Env: &repo.ImageUpdateCurrentEnv{File: ".env.enc", Key: "APP_IMAGE"}}}) if err != nil { t.Fatalf("currentImageUpdateValue returned error: %v", err) } @@ -29,7 +29,7 @@ func TestCurrentImageUpdateValueFromYAMLPath(t *testing.T) { serviceDir := t.TempDir() writeAgentTestFile(t, filepath.Join(serviceDir, "compose.yaml"), "services:\n web:\n image: ghcr.io/example/web:2.0.0@sha256:def\n") - value, tag, digest, err := currentImageUpdateValue(serviceDir, repo.ImageUpdateConfig{Current: repo.ImageUpdateCurrent{YAML: &repo.ImageUpdateCurrentYAML{File: "compose.yaml", Path: "services.web.image"}}}) + value, tag, digest, err := currentImageUpdateValue(serviceDir, repo.ImageUpdateConfig{Current: repo.ImageUpdateCurrent{YAML: &repo.ImageUpdateCurrentYAML{File: "compose.yaml.enc", Path: "services.web.image"}}}) if err != nil { t.Fatalf("currentImageUpdateValue returned error: %v", err) } diff --git a/internal/app/agent/task_exec_test.go b/internal/app/agent/task_exec_test.go index fe38acd..11d40de 100644 --- a/internal/app/agent/task_exec_test.go +++ b/internal/app/agent/task_exec_test.go @@ -514,6 +514,18 @@ func TestExecuteCaddyTasksUseServiceDirForGeneratedFileName(t *testing.T) { } } +func TestResolveServiceCaddySourcePathUsesRuntimeFileName(t *testing.T) { + serviceRoot := t.TempDir() + got, err := resolveServiceCaddySourcePath(serviceRoot, "config/Caddyfile.enc") + if err != nil { + t.Fatalf("resolve encrypted caddy source: %v", err) + } + want := filepath.Join(serviceRoot, "config", "Caddyfile") + if got != want { + t.Fatalf("caddy source = %q, want %q", got, want) + } +} + func TestBuildBackupVolumeFlagsServicePath(t *testing.T) { t.Parallel() diff --git a/internal/app/cli/cli.go b/internal/app/cli/cli.go index 7a62b3a..3e8a5f7 100644 --- a/internal/app/cli/cli.go +++ b/internal/app/cli/cli.go @@ -73,7 +73,6 @@ type controllerClient struct { dockerCommands controllerv1connect.DockerCommandServiceClient repos controllerv1connect.RepoQueryServiceClient repoCommands controllerv1connect.RepoCommandServiceClient - secrets controllerv1connect.SecretServiceClient } // Run executes the user-facing CLI command surface. @@ -96,7 +95,7 @@ func Run(ctx context.Context, args []string, out io.Writer, errOut io.Writer) er func isControllerCommand(command string) bool { switch command { - case "system", serviceCommandName, "instance", "task", "backup", "node", "container", "network", "volume", "image", "rustic", "repo", "secret", "config": + case "system", serviceCommandName, "instance", "task", "backup", "node", "container", "network", "volume", "image", "rustic", "repo", "config": return true default: return false @@ -143,7 +142,6 @@ Commands: container Low-level container operations by node and container ID backup List and restore backups repo Low-level repository file operations - secret Low-level encrypted file operations system Controller status, reload, and capability checks instance Low-level service instance operations by service and node network Low-level Docker network operations by node @@ -250,10 +248,6 @@ var commandUsages = map[string]string{ //nolint:gosec "repo history": "usage: composia repo history [--page-size n] [--cursor cursor]\n", "repo sync": "usage: composia repo sync\n", "repo validate": "usage: composia repo validate\n", - "secret": "usage: composia secret \n", - "secret get": "usage: composia secret get \n", - "secret edit": "usage: composia secret edit [--message text] \n", - "secret update": "usage: composia secret update --file file [--message text] \n", "config": "usage: composia config \n", "config get": "usage: composia config get [key]\n", "config set": "usage: composia config set \n", @@ -629,7 +623,6 @@ func (application *app) configureClient() error { dockerCommands: controllerv1connect.NewDockerCommandServiceClient(httpClient, baseURL, auth), repos: controllerv1connect.NewRepoQueryServiceClient(httpClient, baseURL, auth), repoCommands: controllerv1connect.NewRepoCommandServiceClient(httpClient, baseURL, auth), - secrets: controllerv1connect.NewSecretServiceClient(httpClient, baseURL, auth), } return nil } diff --git a/internal/app/cli/cli_test.go b/internal/app/cli/cli_test.go index a15085e..655cf55 100644 --- a/internal/app/cli/cli_test.go +++ b/internal/app/cli/cli_test.go @@ -94,9 +94,9 @@ func TestStringListFlag(t *testing.T) { } } -func TestIsControllerCommandIncludesSecret(t *testing.T) { - if !isControllerCommand("secret") { - t.Fatalf("secret command is not recognized") +func TestIsControllerCommandExcludesRemovedSecretCommand(t *testing.T) { + if isControllerCommand("secret") { + t.Fatalf("secret command is still recognized") } for _, command := range []string{"network", "volume", "image", "rustic"} { if !isControllerCommand(command) { @@ -308,7 +308,6 @@ func TestUsageIncludesWaitAndNewCommands(t *testing.T) { "node Inspect nodes", "container Low-level container operations", "repo Low-level repository file operations", - "secret Low-level encrypted file operations", "system Controller status", "completion Generate shell completion scripts", "Run 'composia help ' for command details.", diff --git a/internal/app/cli/cobra.go b/internal/app/cli/cobra.go index cb446bb..70cf879 100644 --- a/internal/app/cli/cobra.go +++ b/internal/app/cli/cobra.go @@ -139,11 +139,6 @@ func cobraCommandSpecs(runtime *cobraRuntime) []cobraCommandSpec { leafSpec("sync", "Sync repository remote", true, (*app).runRepoSync, nil, nil), leafSpec("validate", "Validate repository files", true, (*app).runRepoValidate, nil, nil), }), - groupSpec("secret", "Low-level encrypted file operations", true, []cobraCommandSpec{ - leafSpec("get ", "Print a decrypted secret file", true, (*app).runSecretGet, nil, completeServiceFirstArg(runtime)), - leafSpec("edit ", "Edit a decrypted secret file", true, (*app).runSecretEdit, messageFlag, completeServiceFirstArg(runtime)), - leafSpec("update ", "Update an encrypted secret file", true, (*app).runSecretUpdate, fileMessageFlags, completeServiceFirstArg(runtime)), - }), groupSpec("system", "Controller status, reload, and capability checks", true, []cobraCommandSpec{ leafSpec("status", "Show controller status", true, func(a *app, args []string) error { return a.runSystem(append([]string{"status"}, args...)) }, nil, nil), leafSpec("reload", "Reload controller config", true, func(a *app, args []string) error { return a.runSystem(append([]string{"reload"}, args...)) }, nil, nil), diff --git a/internal/app/cli/command_secret.go b/internal/app/cli/command_secret.go deleted file mode 100644 index 79ea2f1..0000000 --- a/internal/app/cli/command_secret.go +++ /dev/null @@ -1,100 +0,0 @@ -package cli - -import ( - "fmt" - - controllerv1 "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/controller/v1" -) - -func (application *app) runSecretGet(args []string) error { - if err := requireArgs(args, 2, "composia secret get "); err != nil { - return err - } - response, err := application.client.secrets.GetSecret(application.ctx, newRequest(&controllerv1.GetSecretRequest{ServiceName: args[0], FilePath: args[1]})) - if err != nil { - return err - } - if application.isJSONOutput() { - return application.printMessage(response.Msg) - } - _, err = fmt.Fprint(application.out, response.Msg.GetContent()) - return err -} - -func (application *app) runSecretEdit(args []string) error { - fs := newCommandFlagSet("secret edit") - message := fs.String("message", "", "commit message") - if err := fs.Parse(args); err != nil { - return err - } - if err := requireArgs(fs.Args(), 2, "composia secret edit [--message text] "); err != nil { - return err - } - serviceName := fs.Arg(0) - filePath := fs.Arg(1) - baseRevision, err := application.currentRepoRevision() - if err != nil { - return err - } - secret, err := application.client.secrets.GetSecret(application.ctx, newRequest(&controllerv1.GetSecretRequest{ServiceName: serviceName, FilePath: filePath})) - if err != nil { - return err - } - updatedContent, changed, err := editText(application.ctx, secret.Msg.GetContent(), "composia-secret-*", 0o600) - if err != nil { - return err - } - if !changed { - _, err := fmt.Fprintln(application.out, "unchanged") - return err - } - response, err := application.client.secrets.UpdateSecret(application.ctx, newRequest(&controllerv1.UpdateSecretRequest{ServiceName: serviceName, FilePath: filePath, Content: updatedContent, BaseRevision: baseRevision, CommitMessage: *message})) - if err != nil { - return repoWriteError(err) - } - if application.isJSONOutput() { - return application.printMessage(response.Msg) - } - return application.printRepoWriteResult(repoWriteResult{ - commitID: response.Msg.GetCommitId(), - syncStatus: response.Msg.GetSyncStatus(), - pushError: response.Msg.GetPushError(), - lastSuccessfulPullAt: response.Msg.GetLastSuccessfulPullAt(), - }) -} - -func (application *app) runSecretUpdate(args []string) error { - fs := newCommandFlagSet("secret update") - fileSource := fs.String("file", "", "plaintext file to read; use - for stdin") - message := fs.String("message", "", "commit message") - if err := fs.Parse(args); err != nil { - return err - } - if err := requireArgs(fs.Args(), 2, "composia secret update --file file [--message text] "); err != nil { - return err - } - if *fileSource == "" { - return errorsWithUsage("file is required", "composia secret update --file file [--message text] ") - } - content, err := readContentSource(*fileSource) - if err != nil { - return err - } - baseRevision, err := application.currentRepoRevision() - if err != nil { - return err - } - response, err := application.client.secrets.UpdateSecret(application.ctx, newRequest(&controllerv1.UpdateSecretRequest{ServiceName: fs.Arg(0), FilePath: fs.Arg(1), Content: content, BaseRevision: baseRevision, CommitMessage: *message})) - if err != nil { - return repoWriteError(err) - } - if application.isJSONOutput() { - return application.printMessage(response.Msg) - } - return application.printRepoWriteResult(repoWriteResult{ - commitID: response.Msg.GetCommitId(), - syncStatus: response.Msg.GetSyncStatus(), - pushError: response.Msg.GetPushError(), - lastSuccessfulPullAt: response.Msg.GetLastSuccessfulPullAt(), - }) -} diff --git a/internal/app/controller/bootstrap.go b/internal/app/controller/bootstrap.go index 254155a..36c40fc 100644 --- a/internal/app/controller/bootstrap.go +++ b/internal/app/controller/bootstrap.go @@ -104,12 +104,6 @@ func registerAccessHandlers(mux *http.ServeMux, cfg *config.ControllerConfig, db ) mountRPCHandler(mux, rpcutil.ControllerAPIBasePath, repoCommandPath, repoCommandHandler) - secretPath, secretHandler := controllerv1connect.NewSecretServiceHandler( - &secretServer{db: db, cfg: cfg, availableNodeIDs: availableNodeIDs, repoMu: repoMu}, - connect.WithInterceptors(interceptor), - ) - mountRPCHandler(mux, rpcutil.ControllerAPIBasePath, secretPath, secretHandler) - backupSvc := &backupServer{db: db, cfg: cfg, availableNodeIDs: availableNodeIDs, taskQueue: taskQueue} backupQueryPath, backupQueryHandler := controllerv1connect.NewBackupQueryServiceHandler( backupSvc, diff --git a/internal/app/controller/bundle.go b/internal/app/controller/bundle.go index 6a3186e..12014b7 100644 --- a/internal/app/controller/bundle.go +++ b/internal/app/controller/bundle.go @@ -59,6 +59,9 @@ func (server *bundleServer) GetServiceBundle(ctx context.Context, req *connect.R } extraFiles, err := bundleExtraFiles(server.cfg, detail.Record, params, params.ServiceDir == requestedServiceDir) if err != nil { + if errors.Is(err, errSecretsNotConfigured) { + return connect.NewError(connect.CodeFailedPrecondition, err) + } return connect.NewError(connect.CodeInternal, err) } diff --git a/internal/app/controller/bundle_runtime.go b/internal/app/controller/bundle_runtime.go index 83b4d43..1bd6bcf 100644 --- a/internal/app/controller/bundle_runtime.go +++ b/internal/app/controller/bundle_runtime.go @@ -19,18 +19,21 @@ func bundleExtraFiles(cfg *config.ControllerConfig, record task.Record, params s if params.ServiceDir == "" { return extraFiles, nil } + encFiles, err := bundleEncryptedFiles(cfg, record, params.ServiceDir) + if err != nil { + return nil, err + } + if len(encFiles) > 0 && cfg.Secrets == nil { + return nil, errSecretsNotConfigured + } if cfg.Secrets != nil { - encFiles, err := listEncryptedFiles(cfg.RepoDir, record.RepoRevision, params.ServiceDir) - if err != nil { - return nil, err - } for _, encFile := range encFiles { fullPath := filepath.ToSlash(filepath.Join(params.ServiceDir, encFile)) decrypted, err := decryptFileAtRevision(cfg, record.RepoRevision, fullPath) if err != nil { return nil, err } - decryptedPath := strings.TrimSuffix(encFile, ".enc") + decryptedPath := repo.RuntimeFilePath(encFile) extraFiles[filepath.ToSlash(filepath.Join(params.ServiceDir, decryptedPath))] = decrypted } } @@ -58,6 +61,41 @@ func bundleExtraFiles(cfg *config.ControllerConfig, record task.Record, params s return extraFiles, nil } +func bundleEncryptedFiles(cfg *config.ControllerConfig, record task.Record, serviceDir string) ([]string, error) { + if record.Type != task.TypeCaddySync { + return listEncryptedFiles(cfg.RepoDir, record.RepoRevision, serviceDir) + } + encFiles, err := listEncryptedFiles(cfg.RepoDir, record.RepoRevision, serviceDir) + if err != nil { + return nil, err + } + service, err := repo.FindServiceAtRevision(cfg.RepoDir, record.RepoRevision, serviceDir, configuredNodeIDs(cfg)) + if err != nil { + return nil, err + } + caddySource := strings.TrimSpace(repo.CaddySource(service)) + normalizedSource, err := repo.NormalizePath(caddySource) + if err != nil { + if repo.IsEncryptedFilePath(caddySource) { + return nil, errInvalidEncryptedPath + } + return nil, nil + } + if !repo.IsEncryptedFilePath(normalizedSource) { + return nil, nil + } + if normalizedSource == "" || repo.HasEncryptedParent(normalizedSource) || !repo.IsValidEncryptedFilePath(normalizedSource) { + return nil, errInvalidEncryptedPath + } + caddySource = filepath.ToSlash(normalizedSource) + for _, encFile := range encFiles { + if encFile == caddySource { + return []string{encFile}, nil + } + } + return nil, nil +} + func listEncryptedFiles(repoDir, revision, serviceDir string) ([]string, error) { files, err := repo.ListFilesAtRevision(repoDir, revision, serviceDir) if err != nil { @@ -68,7 +106,10 @@ func listEncryptedFiles(repoDir, revision, serviceDir string) ([]string, error) } var encFiles []string for _, f := range files { - if strings.HasSuffix(f, ".enc") { + if repo.IsEncryptedFilePath(f) { + if !repo.IsValidEncryptedFilePath(f) { + return nil, fmt.Errorf("invalid encrypted file path %q", f) + } encFiles = append(encFiles, f) } } diff --git a/internal/app/controller/bundle_test.go b/internal/app/controller/bundle_test.go index 37b361f..84016a0 100644 --- a/internal/app/controller/bundle_test.go +++ b/internal/app/controller/bundle_test.go @@ -68,7 +68,7 @@ func TestBundleServiceStreamsTaskBundle(t *testing.T) { return "main", nil }) mux := http.NewServeMux() - bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir}}, connect.WithInterceptors(interceptor)) + bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}}}, connect.WithInterceptors(interceptor)) mux.Handle(bundlePath, bundleHandler) httpServer := httptest.NewServer(mux) defer httpServer.Close() @@ -164,7 +164,7 @@ func TestBundleServiceStreamsCaddySyncBundleWithSingleServiceDir(t *testing.T) { return "main", nil }) mux := http.NewServeMux() - bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir}}, connect.WithInterceptors(interceptor)) + bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}}}, connect.WithInterceptors(interceptor)) mux.Handle(bundlePath, bundleHandler) httpServer := httptest.NewServer(mux) defer httpServer.Close() @@ -198,6 +198,75 @@ func TestBundleServiceStreamsCaddySyncBundleWithSingleServiceDir(t *testing.T) { } } +func TestBundleExtraFilesForCaddySyncOnlyDecryptsCaddySource(t *testing.T) { + t.Parallel() + + rootDir := t.TempDir() + repoDir := filepath.Join(rootDir, "repo") + secretsCfg := writeAgeTestConfig(t, rootDir) + caddyCiphertext, err := secretutil.Encrypt("demo.example.com { reverse_proxy 127.0.0.1:8080 }\n", secretsCfg) + if err != nil { + t.Fatalf("encrypt caddy source: %v", err) + } + secretCiphertext, err := secretutil.Encrypt("TOKEN=secret\n", secretsCfg) + if err != nil { + t.Fatalf("encrypt secret env: %v", err) + } + createGitRepoWithContent(t, repoDir, map[string]string{ + "demo/composia-meta.yaml": "name: demo\nnodes:\n - main\nnetwork:\n caddy:\n enabled: true\n source: ./demo.caddy.enc\n", + "demo/demo.caddy.enc": string(caddyCiphertext), + "demo/.secret.env.enc": string(secretCiphertext), + }) + revision, err := repo.CurrentRevision(repoDir) + if err != nil { + t.Fatalf("read current revision: %v", err) + } + + extraFiles, err := bundleExtraFiles( + &config.ControllerConfig{RepoDir: repoDir, Secrets: secretsCfg, Nodes: []config.NodeConfig{{ID: "main"}}}, + task.Record{Type: task.TypeCaddySync, RepoRevision: revision}, + serviceTaskParams{ServiceDir: "demo"}, + false, + ) + if err != nil { + t.Fatalf("build caddy sync extra files: %v", err) + } + if extraFiles["demo/demo.caddy"] != "demo.example.com { reverse_proxy 127.0.0.1:8080 }\n" { + t.Fatalf("expected decrypted caddy source, got %+v", extraFiles) + } + if _, ok := extraFiles["demo/.secret.env"]; ok { + t.Fatalf("caddy sync must not decrypt unrelated secrets: %+v", extraFiles) + } +} + +func TestBundleExtraFilesForCaddySyncIgnoresUnreferencedEncryptedFilesWithoutSecrets(t *testing.T) { + t.Parallel() + + repoDir := filepath.Join(t.TempDir(), "repo") + createGitRepoWithContent(t, repoDir, map[string]string{ + "demo/composia-meta.yaml": "name: demo\nnodes:\n - main\nnetwork:\n caddy:\n enabled: true\n source: ./demo.caddy\n", + "demo/demo.caddy": "demo.example.com { reverse_proxy 127.0.0.1:8080 }\n", + "demo/.secret.env.enc": "ciphertext\n", + }) + revision, err := repo.CurrentRevision(repoDir) + if err != nil { + t.Fatalf("read current revision: %v", err) + } + + extraFiles, err := bundleExtraFiles( + &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}}, + task.Record{Type: task.TypeCaddySync, RepoRevision: revision}, + serviceTaskParams{ServiceDir: "demo"}, + false, + ) + if err != nil { + t.Fatalf("build caddy sync extra files without secrets config: %v", err) + } + if len(extraFiles) != 0 { + t.Fatalf("expected no caddy sync extra files, got %+v", extraFiles) + } +} + func TestBundleServiceStreamsRequestedServiceDirOverride(t *testing.T) { t.Parallel() @@ -239,7 +308,7 @@ func TestBundleServiceStreamsRequestedServiceDirOverride(t *testing.T) { return "main", nil }) mux := http.NewServeMux() - bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir}}, connect.WithInterceptors(interceptor)) + bundlePath, bundleHandler := agentv1connect.NewBundleServiceHandler(&bundleServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}}}, connect.WithInterceptors(interceptor)) mux.Handle(bundlePath, bundleHandler) httpServer := httptest.NewServer(mux) defer httpServer.Close() @@ -341,8 +410,8 @@ func TestBundleServiceInjectsDecryptedSecretEnv(t *testing.T) { if entries["demo/.secret.env"] != "TOKEN=secret\n" { t.Fatalf("expected decrypted secret env in bundle, got %q", entries["demo/.secret.env"]) } - if _, ok := entries["demo/.secret.env.enc"]; !ok { - t.Fatalf("expected encrypted secret env to remain in bundle entries") + if _, ok := entries["demo/.secret.env.enc"]; ok { + t.Fatalf("encrypted secret env must not remain in bundle entries") } } diff --git a/internal/app/controller/repo_command.go b/internal/app/controller/repo_command.go index 984538e..b0af276 100644 --- a/internal/app/controller/repo_command.go +++ b/internal/app/controller/repo_command.go @@ -83,8 +83,12 @@ func (server *repoCommandServer) UpdateRepoFile(ctx context.Context, req *connec if req.Msg.GetBaseRevision() == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("base_revision is required")) } - result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{req.Msg.GetPath()}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { - return server.updateRepoFileTransaction(ctx, req.Msg.GetPath(), req.Msg.GetContent(), req.Msg.GetCommitMessage(), baseSyncState) + path, err := repo.NormalizePath(req.Msg.GetPath()) + if err != nil || path == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, repo.ErrRepoPathInvalid) + } + result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{path}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { + return server.updateRepoFileTransaction(ctx, path, req.Msg.GetContent(), req.Msg.GetCommitMessage(), baseSyncState) }) if err != nil { return nil, err @@ -99,8 +103,15 @@ func (server *repoCommandServer) CreateRepoDirectory(ctx context.Context, req *c if req.Msg.GetBaseRevision() == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("base_revision is required")) } - result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{req.Msg.GetPath()}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { - return server.createRepoDirectoryTransaction(ctx, req.Msg.GetPath(), req.Msg.GetCommitMessage(), baseSyncState) + path, err := repo.NormalizePath(req.Msg.GetPath()) + if err != nil || path == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, repo.ErrRepoPathInvalid) + } + if repo.IsEncryptedFilePath(path) || repo.HasEncryptedParent(path) { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("directories must not use the .enc suffix")) + } + result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{path}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { + return server.createRepoDirectoryTransaction(ctx, path, req.Msg.GetCommitMessage(), baseSyncState) }) if err != nil { return nil, err @@ -115,8 +126,22 @@ func (server *repoCommandServer) MoveRepoPath(ctx context.Context, req *connect. if req.Msg.GetBaseRevision() == "" { return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("base_revision is required")) } - result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{req.Msg.GetSourcePath(), req.Msg.GetDestinationPath()}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { - return server.moveRepoPathTransaction(ctx, req.Msg.GetSourcePath(), req.Msg.GetDestinationPath(), req.Msg.GetCommitMessage(), baseSyncState) + sourcePath, sourceErr := repo.NormalizePath(req.Msg.GetSourcePath()) + destinationPath, destinationErr := repo.NormalizePath(req.Msg.GetDestinationPath()) + if sourceErr != nil || destinationErr != nil || sourcePath == "" || destinationPath == "" { + return nil, connect.NewError(connect.CodeInvalidArgument, repo.ErrRepoPathInvalid) + } + if repo.HasEncryptedParent(sourcePath) || repo.HasEncryptedParent(destinationPath) { + return nil, connect.NewError(connect.CodeInvalidArgument, errInvalidEncryptedPath) + } + if repo.IsEncryptedFilePath(sourcePath) != repo.IsEncryptedFilePath(destinationPath) { + return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("moves must not cross the .enc storage boundary")) + } + if repo.IsEncryptedFilePath(sourcePath) && (!repo.IsValidEncryptedFilePath(sourcePath) || !repo.IsValidEncryptedFilePath(destinationPath)) { + return nil, connect.NewError(connect.CodeInvalidArgument, errInvalidEncryptedPath) + } + result, err := server.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{sourcePath, destinationPath}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { + return server.moveRepoPathTransaction(ctx, sourcePath, destinationPath, req.Msg.GetCommitMessage(), baseSyncState) }) if err != nil { return nil, err @@ -363,6 +388,16 @@ func pathHitsServiceDir(targetPath, serviceDir string) bool { } func (server *repoCommandServer) updateRepoFileTransaction(ctx context.Context, relativePath, content, commitMessage string, baseSyncState store.RepoSyncState) (_ repoWriteResult, retErr error) { + storedContent, err := storedRepoFileContent(server.cfg, relativePath, content) + if err != nil { + if errors.Is(err, errSecretsNotConfigured) { + return repoWriteResult{}, connect.NewError(connect.CodeFailedPrecondition, err) + } + if errors.Is(err, errInvalidEncryptedPath) { + return repoWriteResult{}, connect.NewError(connect.CodeInvalidArgument, err) + } + return repoWriteResult{}, connect.NewError(connect.CodeInternal, err) + } previous, readErr := repo.ReadFile(server.cfg.RepoDir, relativePath) fileExisted := readErr == nil committed := false @@ -376,11 +411,13 @@ func (server *repoCommandServer) updateRepoFileTransaction(ctx context.Context, return repoWriteResult{}, connect.NewError(connect.CodeInternal, readErr) } } - writtenPath, err := repo.WriteFile(server.cfg.RepoDir, relativePath, content) + writtenPath, err := repo.WriteFile(server.cfg.RepoDir, relativePath, storedContent) if err != nil { switch { case errors.Is(err, repo.ErrRepoPathInvalid), errors.Is(err, repo.ErrRepoPathProtected): return repoWriteResult{}, connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, repo.ErrEncryptedFileConflict): + return repoWriteResult{}, connect.NewError(connect.CodeFailedPrecondition, err) default: return repoWriteResult{}, connect.NewError(connect.CodeInternal, err) } @@ -583,6 +620,8 @@ func mapRepoMutationError(err error) error { switch { case errors.Is(err, repo.ErrRepoPathInvalid), errors.Is(err, repo.ErrRepoPathProtected): return connect.NewError(connect.CodeInvalidArgument, err) + case errors.Is(err, repo.ErrEncryptedFileConflict): + return connect.NewError(connect.CodeFailedPrecondition, err) case errors.Is(err, repo.ErrRepoPathNotFound), errors.Is(err, repo.ErrRepoPathAlreadyExists), errors.Is(err, repo.ErrRepoPathNotFile), errors.Is(err, repo.ErrRepoPathNotDirectory): return connect.NewError(connect.CodeFailedPrecondition, err) default: diff --git a/internal/app/controller/repo_encryption.go b/internal/app/controller/repo_encryption.go new file mode 100644 index 0000000..31e68c4 --- /dev/null +++ b/internal/app/controller/repo_encryption.go @@ -0,0 +1,49 @@ +package controller + +import ( + "errors" + "fmt" + + "forgejo.alexma.top/alexma233/composia/internal/core/config" + "forgejo.alexma.top/alexma233/composia/internal/core/repo" + secretutil "forgejo.alexma.top/alexma233/composia/internal/platform/secret" +) + +var errSecretsNotConfigured = errors.New("controller secrets are not configured") +var errInvalidEncryptedPath = errors.New(".enc is reserved for encrypted files") + +func repoFileContent(cfg *config.ControllerConfig, path, stored string) (string, error) { + if repo.HasEncryptedParent(path) || repo.IsEncryptedFilePath(path) && !repo.IsValidEncryptedFilePath(path) { + return "", errInvalidEncryptedPath + } + if !repo.IsEncryptedFilePath(path) { + return stored, nil + } + if cfg == nil || cfg.Secrets == nil { + return "", errSecretsNotConfigured + } + plaintext, err := secretutil.Decrypt([]byte(stored), cfg.Secrets) + if err != nil { + return "", fmt.Errorf("decrypt repo file %q: %w", path, err) + } + return plaintext, nil +} + +func storedRepoFileContent(cfg *config.ControllerConfig, path, content string) (string, error) { + normalized, err := repo.NormalizePath(path) + if err != nil || normalized == "" || repo.HasEncryptedParent(normalized) || repo.IsEncryptedFilePath(normalized) && !repo.IsValidEncryptedFilePath(normalized) { + return "", errInvalidEncryptedPath + } + path = normalized + if !repo.IsEncryptedFilePath(path) { + return content, nil + } + if cfg == nil || cfg.Secrets == nil { + return "", errSecretsNotConfigured + } + ciphertext, err := secretutil.Encrypt(content, cfg.Secrets) + if err != nil { + return "", fmt.Errorf("encrypt repo file %q: %w", path, err) + } + return string(ciphertext), nil +} diff --git a/internal/app/controller/repo_query.go b/internal/app/controller/repo_query.go index c807f44..9c880ee 100644 --- a/internal/app/controller/repo_query.go +++ b/internal/app/controller/repo_query.go @@ -91,12 +91,27 @@ func (server *repoQueryServer) GetRepoFile(_ context.Context, req *connect.Reque return nil, connect.NewError(connect.CodeInternal, err) } } - response := &controllerv1.GetRepoFileResponse{ - Path: file.Path, - Content: file.Content, - Size: file.Size, + content, err := repoFileContent(server.cfg, file.Path, file.Content) + if err != nil { + if errors.Is(err, errSecretsNotConfigured) { + return nil, connect.NewError(connect.CodeFailedPrecondition, err) + } + if errors.Is(err, errInvalidEncryptedPath) { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + return nil, connect.NewError(connect.CodeDataLoss, err) } - return connect.NewResponse(response), nil + response := connect.NewResponse(&controllerv1.GetRepoFileResponse{ + Path: file.Path, + Content: content, + Size: int64(len(content)), + }) + if repo.IsEncryptedFilePath(file.Path) { + response.Header().Set("Cache-Control", "private, no-store") + response.Header().Set("Pragma", "no-cache") + response.Header().Set("Expires", "0") + } + return response, nil } func (server *repoQueryServer) ListRepoCommits(_ context.Context, req *connect.Request[controllerv1.ListRepoCommitsRequest]) (*connect.Response[controllerv1.ListRepoCommitsResponse], error) { diff --git a/internal/app/controller/repo_test.go b/internal/app/controller/repo_test.go index bc4eb6f..7b9ef00 100644 --- a/internal/app/controller/repo_test.go +++ b/internal/app/controller/repo_test.go @@ -136,6 +136,25 @@ func TestRepoQueryServiceListRepoFilesAndGetRepoFile(t *testing.T) { } } +func TestRepoCommandRejectsEncryptedStorageBoundaryChanges(t *testing.T) { + server := &repoCommandServer{} + _, err := server.MoveRepoPath(context.Background(), connect.NewRequest(&controllerv1.MoveRepoPathRequest{ + SourcePath: "secret.env", + DestinationPath: "secret.env.enc", + BaseRevision: "revision", + })) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("cross-boundary move error = %v", err) + } + _, err = server.CreateRepoDirectory(context.Background(), connect.NewRequest(&controllerv1.CreateRepoDirectoryRequest{ + Path: "secrets.enc/nested", + BaseRevision: "revision", + })) + if connect.CodeOf(err) != connect.CodeInvalidArgument { + t.Fatalf("encrypted directory error = %v", err) + } +} + func TestRepoCommandServiceSyncRepoFastForwardsConfiguredRemote(t *testing.T) { t.Parallel() diff --git a/internal/app/controller/secret.go b/internal/app/controller/secret.go deleted file mode 100644 index 3b6cedf..0000000 --- a/internal/app/controller/secret.go +++ /dev/null @@ -1,120 +0,0 @@ -package controller - -import ( - "context" - "errors" - "fmt" - "path/filepath" - "strings" - "sync" - - "connectrpc.com/connect" - - controllerv1 "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/controller/v1" - "forgejo.alexma.top/alexma233/composia/internal/core/config" - "forgejo.alexma.top/alexma233/composia/internal/core/repo" - secretutil "forgejo.alexma.top/alexma233/composia/internal/platform/secret" - "forgejo.alexma.top/alexma233/composia/internal/platform/store" -) - -type secretServer struct { - db *store.DB - cfg *config.ControllerConfig - availableNodeIDs map[string]struct{} - repoMu *sync.Mutex -} - -func (server *secretServer) GetSecret(ctx context.Context, req *connect.Request[controllerv1.GetSecretRequest]) (*connect.Response[controllerv1.GetSecretResponse], error) { - if req.Msg == nil || req.Msg.GetFilePath() == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("file_path is required")) - } - if req.Msg.GetServiceDir() == "" && req.Msg.GetServiceName() == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("service_dir or service_name is required")) - } - if server.cfg.Secrets == nil { - return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("controller secrets are not configured")) - } - displayName, filePath, err := server.resolveSecretFilePath(req.Msg.GetServiceDir(), req.Msg.GetServiceName(), req.Msg.GetFilePath()) - if err != nil { - return nil, err - } - secretFile, err := repo.ReadFile(server.cfg.RepoDir, filePath) - if err != nil { - if errors.Is(err, repo.ErrRepoPathNotFound) { - return connect.NewResponse(&controllerv1.GetSecretResponse{ServiceName: displayName, FilePath: req.Msg.GetFilePath(), Content: ""}), nil - } - return nil, connect.NewError(connect.CodeInternal, err) - } - plaintext, err := secretutil.Decrypt([]byte(secretFile.Content), server.cfg.Secrets) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - return connect.NewResponse(&controllerv1.GetSecretResponse{ServiceName: displayName, FilePath: req.Msg.GetFilePath(), Content: plaintext}), nil -} - -func (server *secretServer) UpdateSecret(ctx context.Context, req *connect.Request[controllerv1.UpdateSecretRequest]) (*connect.Response[controllerv1.RepoWriteResult], error) { - if req.Msg == nil || req.Msg.GetFilePath() == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("file_path is required")) - } - if req.Msg.GetServiceDir() == "" && req.Msg.GetServiceName() == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("service_dir or service_name is required")) - } - if req.Msg.GetBaseRevision() == "" { - return nil, connect.NewError(connect.CodeInvalidArgument, errors.New("base_revision is required")) - } - if server.cfg.Secrets == nil { - return nil, connect.NewError(connect.CodeFailedPrecondition, errors.New("controller secrets are not configured")) - } - displayName, filePath, err := server.resolveSecretFilePath(req.Msg.GetServiceDir(), req.Msg.GetServiceName(), req.Msg.GetFilePath()) - if err != nil { - return nil, err - } - ciphertext, err := secretutil.Encrypt(req.Msg.GetContent(), server.cfg.Secrets) - if err != nil { - return nil, connect.NewError(connect.CodeInternal, err) - } - repoSrv := &repoCommandServer{db: server.db, cfg: server.cfg, availableNodeIDs: server.availableNodeIDs, repoMu: server.repoMu} - commitMessage := req.Msg.GetCommitMessage() - if commitMessage == "" { - commitMessage = fmt.Sprintf("update encrypted file %s for %s", req.Msg.GetFilePath(), displayName) - } - result, err := repoSrv.runRepoWrite(ctx, req.Msg.GetBaseRevision(), []string{filePath}, func(baseSyncState store.RepoSyncState) (repoWriteResult, error) { - return repoSrv.updateRepoFileTransaction(ctx, filePath, string(ciphertext), commitMessage, baseSyncState) - }) - if err != nil { - return nil, err - } - return connect.NewResponse(repoWriteResultMessage(result)), nil -} - -func (server *secretServer) resolveSecretFilePath(serviceDir, serviceName, filePath string) (string, string, error) { - cleanPath := filepath.ToSlash(filepath.Clean(filePath)) - if strings.HasPrefix(cleanPath, "../") || strings.Contains(cleanPath, "/../") { - return "", "", connect.NewError(connect.CodeInvalidArgument, errors.New("file_path must not escape service directory")) - } - if filepath.IsAbs(cleanPath) { - return "", "", connect.NewError(connect.CodeInvalidArgument, errors.New("file_path must be relative")) - } - if serviceDir != "" { - normalizedDir := filepath.ToSlash(filepath.Clean(serviceDir)) - if strings.HasPrefix(normalizedDir, "../") || strings.Contains(normalizedDir, "/../") { - return "", "", connect.NewError(connect.CodeInvalidArgument, errors.New("service_dir must not escape repo root")) - } - if filepath.IsAbs(normalizedDir) { - return "", "", connect.NewError(connect.CodeInvalidArgument, errors.New("service_dir must be relative")) - } - fullPath := filepath.ToSlash(filepath.Join(normalizedDir, cleanPath)) - displayName := filepath.Base(normalizedDir) - return displayName, fullPath, nil - } - service, err := repo.FindService(server.cfg.RepoDir, server.availableNodeIDs, serviceName) - if err != nil { - return "", "", connect.NewError(connect.CodeNotFound, err) - } - resolvedDir, err := filepath.Rel(server.cfg.RepoDir, service.Directory) - if err != nil { - return "", "", connect.NewError(connect.CodeInternal, fmt.Errorf("resolve service directory: %w", err)) - } - fullPath := filepath.ToSlash(filepath.Join(resolvedDir, cleanPath)) - return service.Name, fullPath, nil -} diff --git a/internal/app/controller/secret_test.go b/internal/app/controller/secret_test.go index b474716..f6cfe9b 100644 --- a/internal/app/controller/secret_test.go +++ b/internal/app/controller/secret_test.go @@ -6,6 +6,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "sync" "testing" @@ -21,7 +22,7 @@ import ( "forgejo.alexma.top/alexma233/composia/internal/platform/store" ) -func TestSecretServiceGetAndUpdateServiceSecretEnv(t *testing.T) { +func TestRepoServicesDecryptAndEncryptFiles(t *testing.T) { t.Parallel() rootDir := t.TempDir() @@ -63,25 +64,29 @@ func TestSecretServiceGetAndUpdateServiceSecretEnv(t *testing.T) { return "test-client", nil }) repoMu := &sync.Mutex{} - path, handler := controllerv1connect.NewSecretServiceHandler( - &secretServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, - connect.WithInterceptors(interceptor), - ) + cfg := &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg} + queryPath, queryHandler := controllerv1connect.NewRepoQueryServiceHandler(&repoQueryServer{db: db, cfg: cfg, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, connect.WithInterceptors(interceptor)) + commandPath, commandHandler := controllerv1connect.NewRepoCommandServiceHandler(&repoCommandServer{db: db, cfg: cfg, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, connect.WithInterceptors(interceptor)) mux := http.NewServeMux() - mux.Handle(path, handler) + mux.Handle(queryPath, queryHandler) + mux.Handle(commandPath, commandHandler) httpServer := httptest.NewServer(mux) defer httpServer.Close() - client := controllerv1connect.NewSecretServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) - getResp, err := client.GetSecret(ctx, connect.NewRequest(&controllerv1.GetSecretRequest{ServiceName: "alpha", FilePath: ".secret.env.enc"})) + queryClient := controllerv1connect.NewRepoQueryServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) + commandClient := controllerv1connect.NewRepoCommandServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) + getResp, err := queryClient.GetRepoFile(ctx, connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: "alpha/.secret.env.enc"})) if err != nil { t.Fatalf("get secret: %v", err) } if getResp.Msg.GetContent() != "TOKEN=before\n" { t.Fatalf("unexpected decrypted content %q", getResp.Msg.GetContent()) } + if getResp.Header().Get("Cache-Control") != "private, no-store" { + t.Fatalf("unexpected encrypted response cache policy %q", getResp.Header().Get("Cache-Control")) + } headRevision := mustCurrentRevision(t, repoDir) - updateResp, err := client.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ServiceName: "alpha", FilePath: ".secret.env.enc", Content: "TOKEN=after\n", BaseRevision: headRevision})) + updateResp, err := commandClient.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "alpha/.secret.env.enc", Content: "TOKEN=after\n", BaseRevision: headRevision})) if err != nil { t.Fatalf("update service secret env: %v", err) } @@ -98,12 +103,39 @@ func TestSecretServiceGetAndUpdateServiceSecretEnv(t *testing.T) { if plaintext != "TOKEN=after\n" { t.Fatalf("unexpected updated plaintext %q", plaintext) } + stored, err := os.ReadFile(filepath.Join(repoDir, "alpha", ".secret.env.enc")) //nolint:gosec + if err != nil || strings.Contains(string(stored), "TOKEN=after") { + t.Fatalf("repo file contains plaintext: %v", err) + } if _, err := os.Stat(filepath.Join(repoDir, "alpha", ".secret.env")); !os.IsNotExist(err) { t.Fatalf("expected no plaintext secret in repo worktree, got err=%v", err) } } -func TestSecretServiceUpdateSecretWithoutRecipientFile(t *testing.T) { +func TestRepoServicesRequireSecretsConfigForEncryptedFiles(t *testing.T) { + t.Parallel() + + repoDir := filepath.Join(t.TempDir(), "repo") + createGitRepoWithContent(t, repoDir, map[string]string{"secret.enc": "ciphertext"}) + cfg := &config.ControllerConfig{RepoDir: repoDir} + queryClient := newRepoQueryServiceClient(t, &repoQueryServer{cfg: cfg}) + commandClient := newRepoCommandServiceClient(t, &repoCommandServer{cfg: cfg, repoMu: &sync.Mutex{}}) + + _, err := queryClient.GetRepoFile(context.Background(), connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: "secret.enc"})) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("encrypted read error = %v", err) + } + _, err = commandClient.UpdateRepoFile(context.Background(), connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "secret.enc", Content: "plain", BaseRevision: mustCurrentRevision(t, repoDir)})) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("encrypted write error = %v", err) + } + _, err = commandClient.UpdateRepoFile(context.Background(), connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "secret.enc/.", Content: "plain", BaseRevision: mustCurrentRevision(t, repoDir)})) + if connect.CodeOf(err) != connect.CodeFailedPrecondition { + t.Fatalf("normalized encrypted write error = %v", err) + } +} + +func TestRepoCommandEncryptsWithoutRecipientFile(t *testing.T) { t.Parallel() rootDir := t.TempDir() @@ -145,8 +177,8 @@ func TestSecretServiceUpdateSecretWithoutRecipientFile(t *testing.T) { return "test-client", nil }) repoMu := &sync.Mutex{} - path, handler := controllerv1connect.NewSecretServiceHandler( - &secretServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, + path, handler := controllerv1connect.NewRepoCommandServiceHandler( + &repoCommandServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, connect.WithInterceptors(interceptor), ) mux := http.NewServeMux() @@ -154,8 +186,8 @@ func TestSecretServiceUpdateSecretWithoutRecipientFile(t *testing.T) { httpServer := httptest.NewServer(mux) defer httpServer.Close() - client := controllerv1connect.NewSecretServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) - updateResp, err := client.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ServiceName: "alpha", FilePath: ".secret.env.enc", Content: "TOKEN=after\n", BaseRevision: mustCurrentRevision(t, repoDir)})) + client := controllerv1connect.NewRepoCommandServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) + updateResp, err := client.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "alpha/.secret.env.enc", Content: "TOKEN=after\n", BaseRevision: mustCurrentRevision(t, repoDir)})) if err != nil { t.Fatalf("update service secret env without recipient file: %v", err) } @@ -171,7 +203,7 @@ func TestSecretServiceUpdateSecretWithoutRecipientFile(t *testing.T) { } } -func TestSecretServiceUpdateRejectsActiveServiceTask(t *testing.T) { +func TestRepoCommandEncryptedUpdateRejectsActiveServiceTask(t *testing.T) { t.Parallel() rootDir := t.TempDir() @@ -202,8 +234,8 @@ func TestSecretServiceUpdateRejectsActiveServiceTask(t *testing.T) { return "test-client", nil }) repoMu := &sync.Mutex{} - path, handler := controllerv1connect.NewSecretServiceHandler( - &secretServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, + path, handler := controllerv1connect.NewRepoCommandServiceHandler( + &repoCommandServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}, repoMu: repoMu}, connect.WithInterceptors(interceptor), ) mux := http.NewServeMux() @@ -211,8 +243,8 @@ func TestSecretServiceUpdateRejectsActiveServiceTask(t *testing.T) { httpServer := httptest.NewServer(mux) defer httpServer.Close() - client := controllerv1connect.NewSecretServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) - _, err = client.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ServiceName: "alpha", FilePath: ".secret.env.enc", Content: "TOKEN=x\n", BaseRevision: mustCurrentRevision(t, repoDir)})) + client := controllerv1connect.NewRepoCommandServiceClient(httpServer.Client(), httpServer.URL, connect.WithInterceptors(rpcutil.NewStaticBearerAuthInterceptor(testAccessToken))) + _, err = client.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "alpha/.secret.env.enc", Content: "TOKEN=x\n", BaseRevision: mustCurrentRevision(t, repoDir)})) if err == nil { t.Fatalf("expected active task conflict") } diff --git a/internal/app/controller/service_command.go b/internal/app/controller/service_command.go index 1309e06..b0f50c3 100644 --- a/internal/app/controller/service_command.go +++ b/internal/app/controller/service_command.go @@ -362,7 +362,11 @@ func (server *serviceCommandServer) applyPlannedServiceImageUpdates(ctx context. if err != nil { return repoWriteResult{}, mapRepoMutationError(err) } - contents[path] = file.Content + content, err := repoFileContent(server.cfg, path, file.Content) + if err != nil { + return repoWriteResult{}, connect.NewError(connect.CodeFailedPrecondition, err) + } + contents[path] = content } changed := false for _, update := range planned { @@ -399,7 +403,11 @@ func (server *serviceCommandServer) applyPlannedServiceImageUpdates(ctx context. return repoWriteResult{}, mapRepoMutationError(err) } writtenSnapshots[path] = current.Content - if _, err := repo.WriteFile(server.cfg.RepoDir, path, previous); err != nil { + stored, err := storedRepoFileContent(server.cfg, path, previous) + if err != nil { + return repoWriteResult{}, connect.NewError(connect.CodeFailedPrecondition, err) + } + if _, err := repo.WriteFile(server.cfg.RepoDir, path, stored); err != nil { return repoWriteResult{}, mapRepoMutationError(err) } } diff --git a/internal/app/controller/service_test.go b/internal/app/controller/service_test.go index 8b27249..3d6ff09 100644 --- a/internal/app/controller/service_test.go +++ b/internal/app/controller/service_test.go @@ -18,6 +18,7 @@ import ( "forgejo.alexma.top/alexma233/composia/internal/core/repo" "forgejo.alexma.top/alexma233/composia/internal/core/task" "forgejo.alexma.top/alexma233/composia/internal/platform/rpcutil" + secretutil "forgejo.alexma.top/alexma233/composia/internal/platform/secret" "forgejo.alexma.top/alexma233/composia/internal/platform/store" ) @@ -1019,9 +1020,14 @@ func TestServiceCommandServiceUpdateWithImageSelectionsReturnsRepoWriteResult(t rootDir := t.TempDir() repoDir := filepath.Join(rootDir, "repo") logDir := filepath.Join(rootDir, "logs") + secretsCfg := writeAgeTestConfig(t, rootDir) + encryptedEnv, err := secretutil.Encrypt("API_VERSION=1.2.3\n", secretsCfg) + if err != nil { + t.Fatalf("encrypt env file: %v", err) + } createGitRepoWithContent(t, repoDir, map[string]string{ - "demo/composia-meta.yaml": "name: demo\nnodes:\n - main\nupdate:\n images:\n api:\n image: ghcr.io/example/api\n current:\n env:\n file: .env\n key: API_VERSION\n discovery:\n sources:\n - type: auto\n filter:\n type: semver\n", - "demo/.env": "API_VERSION=1.2.3\n", + "demo/composia-meta.yaml": "name: demo\nnodes:\n - main\nupdate:\n images:\n api:\n image: ghcr.io/example/api\n current:\n env:\n file: .env.enc\n key: API_VERSION\n discovery:\n sources:\n - type: auto\n filter:\n type: semver\n", + "demo/.env.enc": string(encryptedEnv), }) stateDir := filepath.Join(rootDir, "state") @@ -1078,7 +1084,7 @@ func TestServiceCommandServiceUpdateWithImageSelectionsReturnsRepoWriteResult(t return "test-client", nil }) path, handler := controllerv1connect.NewServiceCommandServiceHandler( - &serviceCommandServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}}, availableNodeIDs: map[string]struct{}{"main": {}}}, + &serviceCommandServer{db: db, cfg: &config.ControllerConfig{RepoDir: repoDir, LogDir: logDir, Nodes: []config.NodeConfig{{ID: "main"}}, Secrets: secretsCfg}, availableNodeIDs: map[string]struct{}{"main": {}}}, connect.WithInterceptors(interceptor), ) mux := http.NewServeMux() @@ -1104,12 +1110,12 @@ func TestServiceCommandServiceUpdateWithImageSelectionsReturnsRepoWriteResult(t if response.Msg.GetRepoWrite() == nil || response.Msg.GetRepoWrite().GetCommitId() == "" { t.Fatalf("expected repo write result, got %+v", response.Msg.GetRepoWrite()) } - updatedEnv, err := os.ReadFile(filepath.Join(repoDir, "demo", ".env")) //nolint:gosec + updatedEnv, err := secretutil.DecryptFile(filepath.Join(repoDir, "demo", ".env.enc"), secretsCfg) if err != nil { t.Fatalf("read updated env file: %v", err) } - if string(updatedEnv) != "API_VERSION=1.3.0@sha256:new\n" { - t.Fatalf("unexpected updated env file:\n%s", string(updatedEnv)) + if updatedEnv != "API_VERSION=1.3.0@sha256:new\n" { + t.Fatalf("unexpected updated env file:\n%s", updatedEnv) } } diff --git a/internal/core/repo/bundle.go b/internal/core/repo/bundle.go index 53056bb..f86c8b6 100644 --- a/internal/core/repo/bundle.go +++ b/internal/core/repo/bundle.go @@ -9,6 +9,7 @@ import ( "io" "os/exec" "path" + "slices" "strings" ) @@ -17,6 +18,14 @@ func StreamServiceBundle(ctx context.Context, repoDir, revision, serviceDir stri } func StreamServiceBundleWithExtras(ctx context.Context, repoDir, revision, serviceDir string, extras map[string]string, writer io.Writer) error { + normalizedExtras := make(map[string]string, len(extras)) + for name, content := range extras { + extraPath, err := normalizeBundleExtraPath(name) + if err != nil { + return err + } + normalizedExtras[extraPath] = content + } commandCtx, cancel := context.WithCancel(ctx) defer cancel() command := exec.CommandContext(commandCtx, "git", "-C", repoDir, "archive", "--format=tar", revision, serviceDir) //nolint:gosec @@ -49,6 +58,23 @@ func StreamServiceBundleWithExtras(ctx context.Context, repoDir, revision, servi waitAfterError() return fmt.Errorf("stream git archive entry: %w", err) } + if header.Typeflag == tar.TypeReg && IsEncryptedFilePath(header.Name) { + if _, replaced := normalizedExtras[RuntimeFilePath(header.Name)]; replaced { + if _, err := io.Copy(io.Discard, tarReader); err != nil { + _ = tarWriter.Close() + _ = gzipWriter.Close() + waitAfterError() + return fmt.Errorf("skip encrypted bundle file %q: %w", header.Name, err) + } + continue + } + } + if _, replaced := normalizedExtras[header.Name]; replaced { + _ = tarWriter.Close() + _ = gzipWriter.Close() + waitAfterError() + return fmt.Errorf("bundle file %q conflicts with an injected runtime file", header.Name) + } clonedHeader := *header if err := tarWriter.WriteHeader(&clonedHeader); err != nil { _ = tarWriter.Close() @@ -65,14 +91,13 @@ func StreamServiceBundleWithExtras(ctx context.Context, repoDir, revision, servi } } } - for name, content := range extras { - extraPath, err := normalizeBundleExtraPath(name) - if err != nil { - _ = tarWriter.Close() - _ = gzipWriter.Close() - waitAfterError() - return err - } + extraPaths := make([]string, 0, len(normalizedExtras)) + for name := range normalizedExtras { + extraPaths = append(extraPaths, name) + } + slices.Sort(extraPaths) + for _, extraPath := range extraPaths { + content := normalizedExtras[extraPath] body := []byte(content) header := &tar.Header{Name: extraPath, Mode: 0o600, Size: int64(len(body))} if err := tarWriter.WriteHeader(header); err != nil { diff --git a/internal/core/repo/bundle_test.go b/internal/core/repo/bundle_test.go index c0f930e..062db41 100644 --- a/internal/core/repo/bundle_test.go +++ b/internal/core/repo/bundle_test.go @@ -90,6 +90,37 @@ func TestStreamServiceBundleWithExtrasRejectsUnsafeExtraPath(t *testing.T) { } } +func TestStreamServiceBundleWithExtrasReplacesEncryptedFile(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + gitRun(t, repoDir, "init") + if err := os.MkdirAll(filepath.Join(repoDir, "app"), 0o750); err != nil { + t.Fatalf("create service dir: %v", err) + } + if err := os.WriteFile(filepath.Join(repoDir, "app", ".env.enc"), []byte("ciphertext"), 0o600); err != nil { + t.Fatalf("write encrypted file: %v", err) + } + gitRun(t, repoDir, "add", ".") + gitRun(t, repoDir, "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "service") + revision, err := CurrentRevision(repoDir) + if err != nil { + t.Fatalf("current revision: %v", err) + } + + var bundle bytes.Buffer + if err := StreamServiceBundleWithExtras(context.Background(), repoDir, revision, "app", map[string]string{"app/.env": "plaintext"}, &bundle); err != nil { + t.Fatalf("stream bundle: %v", err) + } + files := readGzipTarFiles(t, bundle.Bytes()) + if files["app/.env"] != "plaintext" { + t.Fatalf("runtime file = %q", files["app/.env"]) + } + if _, exists := files["app/.env.enc"]; exists { + t.Fatal("encrypted file remained in runtime bundle") + } +} + func readGzipTarFiles(t *testing.T, data []byte) map[string]string { t.Helper() diff --git a/internal/core/repo/data_include.go b/internal/core/repo/data_include.go index 0e1cefa..499d6c3 100644 --- a/internal/core/repo/data_include.go +++ b/internal/core/repo/data_include.go @@ -34,6 +34,9 @@ func ClassifyDataInclude(include string) (DataIncludeKind, string, error) { return "", "", fmt.Errorf("include %q must stay within the service root", include) } if strings.HasPrefix(trimmed, "./") || strings.Contains(trimmed, "/") { + if IsEncryptedFilePath(clean) && !IsValidEncryptedFilePath(clean) { + return "", "", fmt.Errorf("include %q must name a file before .enc", include) + } return DataIncludeKindServicePath, clean, nil } return DataIncludeKindDockerVolume, trimmed, nil diff --git a/internal/core/repo/encrypted_file.go b/internal/core/repo/encrypted_file.go new file mode 100644 index 0000000..63e2b4f --- /dev/null +++ b/internal/core/repo/encrypted_file.go @@ -0,0 +1,79 @@ +package repo + +import ( + "errors" + "fmt" + "path/filepath" + "strings" +) + +const encryptedFileSuffix = ".enc" + +var ErrEncryptedFileConflict = errors.New("encrypted and plaintext files cannot coexist") + +func IsEncryptedFilePath(path string) bool { + return strings.HasSuffix(path, encryptedFileSuffix) +} + +func RuntimeFilePath(path string) string { + return strings.TrimSuffix(path, encryptedFileSuffix) +} + +func IsValidEncryptedFilePath(path string) bool { + return IsEncryptedFilePath(path) && filepath.Base(path) != encryptedFileSuffix +} + +func HasEncryptedParent(path string) bool { + parent := filepath.Dir(path) + for parent != "." && parent != string(filepath.Separator) { + if IsEncryptedFilePath(filepath.Base(parent)) { + return true + } + parent = filepath.Dir(parent) + } + return false +} + +func NormalizePath(path string) (string, error) { + path = filepath.Clean(strings.TrimSpace(path)) + if path == "." { + return "", nil + } + if filepath.IsAbs(path) || path == ".." || strings.HasPrefix(path, ".."+string(filepath.Separator)) { + return "", ErrRepoPathInvalid + } + return path, nil +} + +func ValidateEncryptedFilePaths(paths []string) error { + cleanedPaths := make([]string, 0, len(paths)) + for _, path := range paths { + cleaned := filepath.ToSlash(filepath.Clean(path)) + if cleaned == "." { + continue + } + if HasEncryptedParent(cleaned) || IsEncryptedFilePath(cleaned) && !IsValidEncryptedFilePath(cleaned) { + return fmt.Errorf("%w: %q", ErrRepoPathInvalid, cleaned) + } + cleanedPaths = append(cleanedPaths, cleaned) + } + for _, encryptedPath := range cleanedPaths { + if !IsEncryptedFilePath(encryptedPath) { + continue + } + runtimePath := RuntimeFilePath(encryptedPath) + for _, actualPath := range cleanedPaths { + if actualPath == encryptedPath { + continue + } + if pathIsSameOrChild(actualPath, runtimePath) || pathIsSameOrChild(runtimePath, actualPath) { + return fmt.Errorf("%w: %q and %q", ErrEncryptedFileConflict, runtimePath, actualPath) + } + } + } + return nil +} + +func pathIsSameOrChild(path, parent string) bool { + return path == parent || strings.HasPrefix(path, parent+"/") +} diff --git a/internal/core/repo/encrypted_file_test.go b/internal/core/repo/encrypted_file_test.go new file mode 100644 index 0000000..73fba41 --- /dev/null +++ b/internal/core/repo/encrypted_file_test.go @@ -0,0 +1,39 @@ +package repo + +import ( + "errors" + "testing" +) + +func TestEncryptedFilePaths(t *testing.T) { + if !IsEncryptedFilePath("config/.env.enc") || IsEncryptedFilePath("config/.env.ENC") { + t.Fatal("encrypted file suffix must be exact") + } + if got := RuntimeFilePath("config/.env.enc"); got != "config/.env" { + t.Fatalf("runtime path = %q", got) + } + if got := RuntimeFilePath("config/.env"); got != "config/.env" { + t.Fatalf("plain runtime path = %q", got) + } + if IsValidEncryptedFilePath("config/.enc") || !HasEncryptedParent("config.enc/file") { + t.Fatal("invalid encrypted path was accepted") + } + if got, err := NormalizePath(" config/.env.enc/. "); err != nil || got != "config/.env.enc" { + t.Fatalf("normalized path = %q/%v", got, err) + } +} + +func TestValidateEncryptedFilePathsRejectsRuntimePairs(t *testing.T) { + if err := ValidateEncryptedFilePaths([]string{"config/.env", "config/.env.enc"}); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("collision error = %v", err) + } + if err := ValidateEncryptedFilePaths([]string{"config/.env.enc", "config/.env"}); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("reverse collision error = %v", err) + } + if err := ValidateEncryptedFilePaths([]string{"config/.env.enc", "config/.env/file"}); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("runtime parent collision error = %v", err) + } + if err := ValidateEncryptedFilePaths([]string{"config.enc/file"}); !errors.Is(err, ErrRepoPathInvalid) { + t.Fatalf("encrypted parent error = %v", err) + } +} diff --git a/internal/core/repo/files.go b/internal/core/repo/files.go index 41f4e66..8541fe6 100644 --- a/internal/core/repo/files.go +++ b/internal/core/repo/files.go @@ -206,6 +206,12 @@ func WriteFile(repoDir, relativePath, content string) (string, error) { if normalizedPath == gitDirName || strings.HasPrefix(normalizedPath, gitDirName+"/") { return "", ErrRepoPathProtected } + if HasEncryptedParent(normalizedPath) || (IsEncryptedFilePath(normalizedPath) && !IsValidEncryptedFilePath(normalizedPath)) { + return "", ErrRepoPathInvalid + } + if err := rejectEncryptedFileConflict(repoDir, normalizedPath); err != nil { + return "", err + } if err := rejectSymlinkPath(repoDir, normalizedPath, true); err != nil { return "", err } @@ -234,6 +240,12 @@ func CreateDirectory(repoDir, relativePath string) (string, error) { if normalizedPath == gitDirName || strings.HasPrefix(normalizedPath, gitDirName+"/") { return "", ErrRepoPathProtected } + if HasEncryptedParent(normalizedPath) || IsEncryptedFilePath(normalizedPath) { + return "", ErrRepoPathInvalid + } + if err := rejectEncryptedFileConflict(repoDir, normalizedPath); err != nil { + return "", err + } if err := rejectSymlinkPath(repoDir, normalizedPath, true); err != nil { return "", err } @@ -297,6 +309,9 @@ func MovePath(repoDir, sourcePath, destinationPath string) (string, string, erro if pathValue == ".git" || strings.HasPrefix(pathValue, ".git/") { return "", "", ErrRepoPathProtected } + if HasEncryptedParent(pathValue) || (IsEncryptedFilePath(pathValue) && !IsValidEncryptedFilePath(pathValue)) { + return "", "", ErrRepoPathInvalid + } } if err := rejectSymlinkPath(repoDir, normalizedSourcePath, false); err != nil { return "", "", err @@ -311,11 +326,17 @@ func MovePath(repoDir, sourcePath, destinationPath string) (string, string, erro } return "", "", fmt.Errorf("stat repo source path %q: %w", normalizedSourcePath, err) } + if info.IsDir() && (IsEncryptedFilePath(normalizedSourcePath) || IsEncryptedFilePath(normalizedDestinationPath)) { + return "", "", ErrRepoPathInvalid + } if _, err := os.Lstat(absDestinationPath); err == nil { return "", "", ErrRepoPathAlreadyExists } else if !errors.Is(err, os.ErrNotExist) { return "", "", fmt.Errorf("stat repo destination path %q: %w", normalizedDestinationPath, err) } + if err := rejectEncryptedMoveConflict(repoDir, normalizedSourcePath, normalizedDestinationPath); err != nil { + return "", "", err + } if info.IsDir() && strings.HasPrefix(normalizedDestinationPath+"/", normalizedSourcePath+"/") { return "", "", ErrRepoPathInvalid } @@ -328,6 +349,116 @@ func MovePath(repoDir, sourcePath, destinationPath string) (string, string, erro return filepath.ToSlash(normalizedSourcePath), filepath.ToSlash(normalizedDestinationPath), nil } +func rejectEncryptedFileConflict(repoDir, relativePath string) error { + if !IsValidEncryptedFilePath(relativePath) && !isPlainRuntimePath(relativePath) { + return nil + } + conflicts := []string{encryptedSiblingPath(relativePath)} + if !IsEncryptedFilePath(relativePath) { + for parent := filepath.Dir(relativePath); parent != "." && parent != string(filepath.Separator); parent = filepath.Dir(parent) { + conflicts = append(conflicts, parent+encryptedFileSuffix) + } + } + for _, conflict := range conflicts { + if conflict == "" { + continue + } + if _, err := os.Lstat(filepath.Join(repoDir, filepath.FromSlash(conflict))); err == nil { + return fmt.Errorf("%w: %q and %q", ErrEncryptedFileConflict, relativePath, conflict) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check encrypted file conflict for %q: %w", relativePath, err) + } + } + return nil +} + +func rejectEncryptedMoveConflict(repoDir, sourcePath, destinationPath string) error { + if !IsValidEncryptedFilePath(destinationPath) && !isPlainRuntimePath(destinationPath) { + return nil + } + sibling := encryptedSiblingPath(destinationPath) + if sibling == "" { + return nil + } + if _, err := os.Lstat(filepath.Join(repoDir, filepath.FromSlash(sibling))); err == nil { + if sibling == sourcePath { + return nil + } + return fmt.Errorf("%w: %q and %q", ErrEncryptedFileConflict, destinationPath, sibling) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("check encrypted move conflict for %q: %w", destinationPath, err) + } + + // A directory move can create a conflict below the destination even when + // the destination entry itself does not exist. + var paths []string + if err := filepath.WalkDir(repoDir, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(repoDir, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if relative == ".git" { + return filepath.SkipDir + } + if relative == sourcePath || strings.HasPrefix(relative, sourcePath+"/") { + if entry.IsDir() { + return nil + } + return nil + } + if entry.IsDir() { + return nil + } + paths = append(paths, relative) + return nil + }); err != nil { + return fmt.Errorf("scan encrypted move destination %q: %w", destinationPath, err) + } + if err := filepath.WalkDir(filepath.Join(repoDir, filepath.FromSlash(sourcePath)), func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + relative, err := filepath.Rel(repoDir, path) + if err != nil { + return err + } + relative = filepath.ToSlash(relative) + if relative == sourcePath { + paths = append(paths, destinationPath) + } else { + paths = append(paths, filepath.ToSlash(filepath.Join(destinationPath, strings.TrimPrefix(relative, sourcePath+"/")))) + } + return nil + }); err != nil { + return fmt.Errorf("scan encrypted move source %q: %w", sourcePath, err) + } + if err := ValidateEncryptedFilePaths(paths); err != nil { + return err + } + return nil +} + +func isPlainRuntimePath(path string) bool { + return path != "" && !IsEncryptedFilePath(path) && filepath.Base(path) != ".enc" +} + +func encryptedSiblingPath(path string) string { + if IsEncryptedFilePath(path) { + if !IsValidEncryptedFilePath(path) { + return "" + } + return RuntimeFilePath(path) + } + return path + encryptedFileSuffix +} + func CapturePath(repoDir, relativePath string) (PathSnapshot, error) { absPath, normalizedPath, err := resolveRepoPath(repoDir, relativePath) if err != nil { @@ -529,17 +660,10 @@ func rejectSymlinkPath(repoDir, normalizedPath string, _ bool) error { } func resolveRepoPath(repoDir, relativePath string) (string, string, error) { - relativePath = filepath.Clean(strings.TrimSpace(relativePath)) - if relativePath == "." { - relativePath = "" - } - if relativePath != "" { - if filepath.IsAbs(relativePath) { - return "", "", ErrRepoPathInvalid - } - if relativePath == ".." || strings.HasPrefix(relativePath, ".."+string(filepath.Separator)) { - return "", "", ErrRepoPathInvalid - } + var err error + relativePath, err = NormalizePath(relativePath) + if err != nil { + return "", "", err } absRepoDir, err := filepath.Abs(repoDir) diff --git a/internal/core/repo/files_test.go b/internal/core/repo/files_test.go index fb9edb7..938cb31 100644 --- a/internal/core/repo/files_test.go +++ b/internal/core/repo/files_test.go @@ -105,6 +105,38 @@ func TestReadFileReturnsContentAndSize(t *testing.T) { } } +func TestWriteFileRejectsEncryptedRuntimePair(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + if _, err := WriteFile(repoDir, "config/.env", "plain"); err != nil { + t.Fatalf("write plain file: %v", err) + } + if _, err := WriteFile(repoDir, "config/.env.enc", "ciphertext"); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("encrypted pair error = %v", err) + } +} + +func TestWriteFileRejectsEncryptedRuntimeParentPair(t *testing.T) { + t.Parallel() + + repoDir := t.TempDir() + if _, err := WriteFile(repoDir, "config.enc", "ciphertext"); err != nil { + t.Fatalf("write encrypted parent: %v", err) + } + if _, err := WriteFile(repoDir, "config/file", "plain"); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("encrypted parent pair error = %v", err) + } + + otherRepoDir := t.TempDir() + if _, err := WriteFile(otherRepoDir, "config/file", "plain"); err != nil { + t.Fatalf("write nested plain file: %v", err) + } + if _, err := WriteFile(otherRepoDir, "config.enc", "ciphertext"); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("encrypted runtime directory error = %v", err) + } +} + func TestResolveRepoPathRejectsTraversal(t *testing.T) { t.Parallel() diff --git a/internal/core/repo/git.go b/internal/core/repo/git.go index f7fcf71..3d34104 100644 --- a/internal/core/repo/git.go +++ b/internal/core/repo/git.go @@ -216,6 +216,15 @@ func ListFilesAtRevision(repoDir, revision, relativePath string) ([]string, erro return lines, nil } +func ValidateEncryptedFileCollisionsAtRevision(repoDir, revision string) error { + revision = strings.TrimSpace(revision) + paths, err := ListFilesAtRevision(repoDir, revision, "") + if err != nil { + return err + } + return ValidateEncryptedFilePaths(paths) +} + func DiffChangedFiles(repoDir, oldRevision, newRevision string) ([]string, error) { output, err := gitOutput(repoDir, "diff", "--name-only", oldRevision, newRevision) if err != nil { @@ -238,6 +247,13 @@ func FetchAndFastForward(repoDir, remoteURL, branch, authUsername, authToken str if _, err := gitOutputWithOptions(repoDir, nil, gitRemoteConfig(remoteURL, authUsername, authToken), "fetch", remoteURL, branch); err != nil { return fmt.Errorf("fetch remote branch %q: %w", branch, err) } + fetchedRevision, err := gitOutput(repoDir, "rev-parse", "FETCH_HEAD") + if err != nil { + return fmt.Errorf("resolve fetched revision: %w", err) + } + if err := ValidateEncryptedFileCollisionsAtRevision(repoDir, strings.TrimSpace(fetchedRevision)); err != nil { + return fmt.Errorf("reject fetched repository: %w", err) + } if err := gitCommand(repoDir, nil, "merge", "--ff-only", "FETCH_HEAD"); err != nil { return fmt.Errorf("fast-forward to fetched HEAD: %w", err) } diff --git a/internal/core/repo/git_test.go b/internal/core/repo/git_test.go index d0b66f9..b5df7d0 100644 --- a/internal/core/repo/git_test.go +++ b/internal/core/repo/git_test.go @@ -163,6 +163,28 @@ func TestGitRemoteConfigUsesBearerTokenWithoutUsername(t *testing.T) { } } +func TestValidateEncryptedFileCollisionsAtRevision(t *testing.T) { + repoDir := t.TempDir() + gitRun(t, repoDir, "init") + if err := os.MkdirAll(filepath.Join(repoDir, "app"), 0o750); err != nil { + t.Fatalf("create app: %v", err) + } + for _, name := range []string{"app/.env", "app/.env.enc"} { + if err := os.WriteFile(filepath.Join(repoDir, name), []byte("content\n"), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } + } + gitRun(t, repoDir, "add", ".") + gitRun(t, repoDir, "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", "commit", "-m", "invalid pair") + revision, err := gitOutput(repoDir, "rev-parse", "HEAD") + if err != nil { + t.Fatalf("resolve revision: %v", err) + } + if err := ValidateEncryptedFileCollisionsAtRevision(repoDir, revision); !errors.Is(err, ErrEncryptedFileConflict) { + t.Fatalf("revision collision error = %v", err) + } +} + func TestGitRemoteConfigUsesBasicAuthWhenUsernameConfigured(t *testing.T) { t.Parallel() diff --git a/internal/core/repo/services.go b/internal/core/repo/services.go index 7f00ee9..cd06fa3 100644 --- a/internal/core/repo/services.go +++ b/internal/core/repo/services.go @@ -645,6 +645,9 @@ func validateNetwork(path string, network *NetworkConfig) error { if network.Caddy != nil && boolValue(network.Caddy.Enabled, false) && network.Caddy.Source == "" { return fmt.Errorf("service meta %q: network.caddy.source is required when caddy is enabled", path) } + if network.Caddy != nil && IsEncryptedFilePath(strings.TrimSpace(network.Caddy.Source)) && !IsValidEncryptedFilePath(strings.TrimSpace(network.Caddy.Source)) { + return fmt.Errorf("service meta %q: network.caddy.source must name a file before .enc", path) + } if network.DNS != nil { switch network.DNS.Provider { @@ -718,6 +721,7 @@ func (meta ServiceMeta) NormalizedComposeFiles() ([]string, error) { } normalized := make([]string, 0, len(meta.ComposeFiles)) seen := make(map[string]struct{}, len(meta.ComposeFiles)) + seenRuntime := make(map[string]struct{}, len(meta.ComposeFiles)) for index, file := range meta.ComposeFiles { trimmed := strings.TrimSpace(file) if trimmed == "" { @@ -730,10 +734,18 @@ func (meta ServiceMeta) NormalizedComposeFiles() ([]string, error) { if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { return nil, fmt.Errorf("compose_files[%d] must stay within the service directory", index) } + if IsEncryptedFilePath(cleaned) && !IsValidEncryptedFilePath(cleaned) { + return nil, fmt.Errorf("compose_files[%d] must name a file before .enc", index) + } if _, exists := seen[cleaned]; exists { return nil, fmt.Errorf("compose_files[%d] duplicates %q", index, cleaned) } + runtimePath := RuntimeFilePath(cleaned) + if _, exists := seenRuntime[runtimePath]; exists { + return nil, fmt.Errorf("compose_files[%d] duplicates runtime path %q", index, runtimePath) + } seen[cleaned] = struct{}{} + seenRuntime[runtimePath] = struct{}{} normalized = append(normalized, cleaned) } return normalized, nil @@ -920,6 +932,9 @@ func validateImageUpdateCurrentFile(fieldPrefix, file string) error { if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) { return fmt.Errorf("%s must stay within the service directory", fieldPrefix) } + if IsEncryptedFilePath(cleaned) && !IsValidEncryptedFilePath(cleaned) { + return fmt.Errorf("%s must name a file before .enc", fieldPrefix) + } return nil } diff --git a/internal/core/repo/services_test.go b/internal/core/repo/services_test.go index a97f625..d23b800 100644 --- a/internal/core/repo/services_test.go +++ b/internal/core/repo/services_test.go @@ -117,6 +117,17 @@ network: } } +func TestNormalizedComposeFilesRejectsRuntimeCollisions(t *testing.T) { + _, err := (ServiceMeta{ComposeFiles: []string{"compose.yaml", "compose.yaml.enc"}}).NormalizedComposeFiles() + if err == nil || !strings.Contains(err.Error(), "runtime path") { + t.Fatalf("runtime collision error = %v", err) + } + _, err = (ServiceMeta{ComposeFiles: []string{".enc"}}).NormalizedComposeFiles() + if err == nil { + t.Fatal("bare .enc compose file was accepted") + } +} + func TestDiscoverServicesSkipsServiceWithoutTargetNodes(t *testing.T) { t.Parallel() diff --git a/proto/composia/controller/v1/repo.proto b/proto/composia/controller/v1/repo.proto index 70a405f..02f4a3b 100644 --- a/proto/composia/controller/v1/repo.proto +++ b/proto/composia/controller/v1/repo.proto @@ -10,7 +10,7 @@ service RepoQueryService { rpc GetRepoHead(GetRepoHeadRequest) returns (GetRepoHeadResponse); // ListRepoFiles lists direct children for one repo path. rpc ListRepoFiles(ListRepoFilesRequest) returns (ListRepoFilesResponse); - // GetRepoFile returns the content of one repo file. + // GetRepoFile returns plaintext for .enc files and stored content for other files. rpc GetRepoFile(GetRepoFileRequest) returns (GetRepoFileResponse); // ListRepoCommits returns commit history using cursor pagination. rpc ListRepoCommits(ListRepoCommitsRequest) returns (ListRepoCommitsResponse); @@ -20,7 +20,7 @@ service RepoQueryService { // RepoCommandService applies changes to the controller Git repo. service RepoCommandService { - // UpdateRepoFile writes one file, creates a commit, and reports sync state. + // UpdateRepoFile encrypts plaintext for .enc files, writes one file, creates a commit, and reports sync state. rpc UpdateRepoFile(UpdateRepoFileRequest) returns (RepoWriteResult); // CreateRepoDirectory creates one directory, creates a commit, and reports sync state. rpc CreateRepoDirectory(CreateRepoDirectoryRequest) returns (RepoWriteResult); @@ -81,13 +81,13 @@ message GetRepoFileRequest { string path = 1; } -// GetRepoFileResponse returns a repo file and its text content. +// GetRepoFileResponse returns a repo file and its API-visible text content. message GetRepoFileResponse { // path is the repo-relative path that was read. string path = 1; - // content is the file content as text. + // content is decrypted plaintext for .enc files and stored text for other files. string content = 2; - // size is the file size in bytes. + // size is the byte length of content. ListRepoFiles reports stored sizes instead. int64 size = 3; } @@ -137,7 +137,7 @@ message ValidateRepoResponse { message UpdateRepoFileRequest { // path is the repo-relative path to update. string path = 1; - // content is the full replacement file content. + // content is plaintext for .enc files and stored text for other files. string content = 2; // base_revision protects against writing on top of an unexpected HEAD. string base_revision = 3; diff --git a/proto/composia/controller/v1/secret.proto b/proto/composia/controller/v1/secret.proto deleted file mode 100644 index db32fa3..0000000 --- a/proto/composia/controller/v1/secret.proto +++ /dev/null @@ -1,43 +0,0 @@ -syntax = "proto3"; - -package composia.controller.v1; - -import "proto/composia/controller/v1/repo.proto"; - -option go_package = "forgejo.alexma.top/alexma233/composia/gen/go/proto/composia/controller/v1;controllerv1"; - -// SecretService reads and updates encrypted secret files stored in the repo. -service SecretService { - // GetSecret returns the decrypted content for one service secret file. - rpc GetSecret(GetSecretRequest) returns (GetSecretResponse); - // UpdateSecret writes one secret file and reports the resulting repo sync state. - rpc UpdateSecret(UpdateSecretRequest) returns (RepoWriteResult); -} - -// GetSecretRequest identifies one decrypted secret file for a service. -message GetSecretRequest { - string service_name = 1; - string file_path = 2; - string service_dir = 3; -} - -// GetSecretResponse returns one decrypted secret file. -message GetSecretResponse { - string service_name = 1; - string file_path = 2; - string content = 3; -} - -// UpdateSecretRequest writes one decrypted secret file for a service. -message UpdateSecretRequest { - string service_name = 1; - // file_path is the repo-relative secret file path for the service. - string file_path = 2; - // content is the full decrypted secret file content to store. - string content = 3; - // base_revision protects against writing on top of an unexpected HEAD. - string base_revision = 4; - // commit_message is used for the generated Git commit. - string commit_message = 5; - string service_dir = 6; -} diff --git a/site/content/docs/developer-guide/api/controller-reference.md b/site/content/docs/developer-guide/api/controller-reference.md index 93cba94..4f58039 100644 --- a/site/content/docs/developer-guide/api/controller-reference.md +++ b/site/content/docs/developer-guide/api/controller-reference.md @@ -157,13 +157,6 @@ weight: 10 - [RepoCommandService](#composia-controller-v1-RepoCommandService) - [RepoQueryService](#composia-controller-v1-RepoQueryService) -- [proto/composia/controller/v1/secret.proto](#proto_composia_controller_v1_secret-proto) - - [GetSecretRequest](#composia-controller-v1-GetSecretRequest) - - [GetSecretResponse](#composia-controller-v1-GetSecretResponse) - - [UpdateSecretRequest](#composia-controller-v1-UpdateSecretRequest) - - - [SecretService](#composia-controller-v1-SecretService) - - [proto/composia/controller/v1/service.proto](#proto_composia_controller_v1_service-proto) - [GetServiceImageUpdateChecksRequest](#composia-controller-v1-GetServiceImageUpdateChecksRequest) - [GetServiceImageUpdateChecksResponse](#composia-controller-v1-GetServiceImageUpdateChecksResponse) @@ -2174,14 +2167,14 @@ GetRepoFileRequest addresses one repo-relative file path. ### GetRepoFileResponse -GetRepoFileResponse returns a repo file and its text content. +GetRepoFileResponse returns a repo file and its API-visible text content. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | path | [string](#string) | | path is the repo-relative path that was read. | -| content | [string](#string) | | content is the file content as text. | -| size | [int64](#int64) | | size is the file size in bytes. | +| content | [string](#string) | | content is decrypted plaintext for .enc files and stored text for other files. | +| size | [int64](#int64) | | size is the byte length of content. ListRepoFiles reports stored sizes instead. | @@ -2408,7 +2401,7 @@ UpdateRepoFileRequest writes one file at a repo-relative path. | Field | Type | Label | Description | | ----- | ---- | ----- | ----------- | | path | [string](#string) | | path is the repo-relative path to update. | -| content | [string](#string) | | content is the full replacement file content. | +| content | [string](#string) | | content is plaintext for .enc files and stored text for other files. | | base_revision | [string](#string) | | base_revision protects against writing on top of an unexpected HEAD. | | commit_message | [string](#string) | | commit_message is used for the generated Git commit. | @@ -2455,7 +2448,7 @@ RepoCommandService applies changes to the controller Git repo. | Method Name | Request Type | Response Type | Description | | ----------- | ------------ | ------------- | ------------| -| UpdateRepoFile | [UpdateRepoFileRequest](#composia-controller-v1-UpdateRepoFileRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | UpdateRepoFile writes one file, creates a commit, and reports sync state. | +| UpdateRepoFile | [UpdateRepoFileRequest](#composia-controller-v1-UpdateRepoFileRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | UpdateRepoFile encrypts plaintext for .enc files, writes one file, creates a commit, and reports sync state. | | CreateRepoDirectory | [CreateRepoDirectoryRequest](#composia-controller-v1-CreateRepoDirectoryRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | CreateRepoDirectory creates one directory, creates a commit, and reports sync state. | | MoveRepoPath | [MoveRepoPathRequest](#composia-controller-v1-MoveRepoPathRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | MoveRepoPath moves or renames one repo path. | | DeleteRepoPath | [DeleteRepoPathRequest](#composia-controller-v1-DeleteRepoPathRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | DeleteRepoPath deletes one repo path. | @@ -2471,7 +2464,7 @@ RepoQueryService exposes read-only views of the controller Git repo. | ----------- | ------------ | ------------- | ------------| | GetRepoHead | [GetRepoHeadRequest](#composia-controller-v1-GetRepoHeadRequest) | [GetRepoHeadResponse](#composia-controller-v1-GetRepoHeadResponse) | GetRepoHead returns current HEAD and sync metadata. | | ListRepoFiles | [ListRepoFilesRequest](#composia-controller-v1-ListRepoFilesRequest) | [ListRepoFilesResponse](#composia-controller-v1-ListRepoFilesResponse) | ListRepoFiles lists direct children for one repo path. | -| GetRepoFile | [GetRepoFileRequest](#composia-controller-v1-GetRepoFileRequest) | [GetRepoFileResponse](#composia-controller-v1-GetRepoFileResponse) | GetRepoFile returns the content of one repo file. | +| GetRepoFile | [GetRepoFileRequest](#composia-controller-v1-GetRepoFileRequest) | [GetRepoFileResponse](#composia-controller-v1-GetRepoFileResponse) | GetRepoFile returns plaintext for .enc files and stored content for other files. | | ListRepoCommits | [ListRepoCommitsRequest](#composia-controller-v1-ListRepoCommitsRequest) | [ListRepoCommitsResponse](#composia-controller-v1-ListRepoCommitsResponse) | ListRepoCommits returns commit history using cursor pagination. | | ValidateRepo | [ValidateRepoRequest](#composia-controller-v1-ValidateRepoRequest) | [ValidateRepoResponse](#composia-controller-v1-ValidateRepoResponse) | ValidateRepo runs repo validation and returns structured errors. | @@ -2479,87 +2472,6 @@ RepoQueryService exposes read-only views of the controller Git repo. - -

Top

- -## proto/composia/controller/v1/secret.proto - - - - - -### GetSecretRequest -GetSecretRequest identifies one decrypted secret file for a service. - - -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| service_name | [string](#string) | | | -| file_path | [string](#string) | | | -| service_dir | [string](#string) | | | - - - - - - - - -### GetSecretResponse -GetSecretResponse returns one decrypted secret file. - - -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| service_name | [string](#string) | | | -| file_path | [string](#string) | | | -| content | [string](#string) | | | - - - - - - - - -### UpdateSecretRequest -UpdateSecretRequest writes one decrypted secret file for a service. - - -| Field | Type | Label | Description | -| ----- | ---- | ----- | ----------- | -| service_name | [string](#string) | | | -| file_path | [string](#string) | | file_path is the repo-relative secret file path for the service. | -| content | [string](#string) | | content is the full decrypted secret file content to store. | -| base_revision | [string](#string) | | base_revision protects against writing on top of an unexpected HEAD. | -| commit_message | [string](#string) | | commit_message is used for the generated Git commit. | -| service_dir | [string](#string) | | | - - - - - - - - - - - - - - -### SecretService -SecretService reads and updates encrypted secret files stored in the repo. - -| Method Name | Request Type | Response Type | Description | -| ----------- | ------------ | ------------- | ------------| -| GetSecret | [GetSecretRequest](#composia-controller-v1-GetSecretRequest) | [GetSecretResponse](#composia-controller-v1-GetSecretResponse) | GetSecret returns the decrypted content for one service secret file. | -| UpdateSecret | [UpdateSecretRequest](#composia-controller-v1-UpdateSecretRequest) | [RepoWriteResult](#composia-controller-v1-RepoWriteResult) | UpdateSecret writes one secret file and reports the resulting repo sync state. | - - - - -

Top

diff --git a/site/content/docs/guide/image-updates.md b/site/content/docs/guide/image-updates.md index f03ae19..18f8046 100644 --- a/site/content/docs/guide/image-updates.md +++ b/site/content/docs/guide/image-updates.md @@ -120,7 +120,7 @@ current: key: APP_VERSION ``` -The `file` path is relative to the service directory. Composia reads the file, looks for `KEY=VALUE` lines, and extracts the value. +The `file` path is relative to the service directory and uses the repository filename. For an encrypted source, specify `.env.enc`; Composia reads `.env` after decrypting it on the agent and re-encrypts controller-side updates into `.env.enc`. **YAML file:** diff --git a/site/content/docs/guide/secrets.de.md b/site/content/docs/guide/secrets.de.md index d6a5c82..849d0fd 100644 --- a/site/content/docs/guide/secrets.de.md +++ b/site/content/docs/guide/secrets.de.md @@ -55,28 +55,30 @@ Während des Render-Schritts einer Deploy- oder Update-Aufgabe: 1. Liest der Controller verschlüsselte Dateien aus dem Dienstverzeichnis im Repo. 2. Entschlüsselt jede Datei mit dem privaten Age-Schlüssel. -3. Injiziert den entschlüsselten Inhalt als `.composia-secret.env` in das Dienstpaket. +3. Entfernt die Endung `.enc` und fügt den entschlüsselten Inhalt unter dem Laufzeitnamen ein. `.secret.env.enc` wird beispielsweise zu `.secret.env`. Das Paket wird über die Agent-Report-Verbindung an den Agenten gestreamt. Der Agent schreibt das Paket auf die Festplatte und fährt mit `docker compose up` fort. Die entschlüsselte Secret-Umgebung steht den Compose-Diensten zur Verfügung, ohne dass der Agent jemals den privaten Schlüssel sieht. +Dateiverweise in `composia-meta.yaml` verwenden Repository-Namen wie `.env.enc`; Composia wandelt sie vor der Laufzeitnutzung in `.env` um. Native Compose-Dateien, Caddyfiles und Skripte verwenden direkt den Laufzeitnamen `.env`. + ## CLI-Nutzung Schreibe eine verschlüsselte Secret-Datei: ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` Lese und entschlüssele eine Secret-Datei: ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` Bearbeite ein Secret direkt (öffnet deinen Editor): ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` Alle Secret-Schreiboperationen beinhalten eine Basis-Revisionsprüfung, um Konflikte mit gleichzeitigen Änderungen zu verhindern. @@ -93,6 +95,6 @@ Der Controller lokalisiert den Dienst, löst den Dateipfad relativ zum Dienstver ## Fehlerbedingungen -- **Secrets nicht konfiguriert**: `GetSecret` und `UpdateSecret` geben `FailedPrecondition` zurück, wenn `controller.secrets` nicht gesetzt ist. -- **Datei nicht gefunden**: `GetSecret` gibt eine leere Inhaltsantwort statt eines Fehlers zurück, wenn die Datei nicht existiert. Dies ermöglicht Clients, zwischen fehlenden Dateien und Entschlüsselungsfehlern zu unterscheiden. -- **Basis-Revisionskonflikt**: `UpdateSecret` verwendet CAS (Compare-and-Swap) gegen den Repo-HEAD. Wenn sich das Repo seit dem letzten Lesen geändert hat, schlägt das Schreiben mit einem Revisionskonflikt fehl. +- **Secrets nicht konfiguriert**: Repo-Zugriffe auf `.enc` geben `FailedPrecondition` zurück. +- **Datei nicht gefunden**: `GetRepoFile` gibt `NotFound` zurück. +- **Basis-Revisionskonflikt**: `UpdateRepoFile` schützt den Repo-HEAD mit CAS. diff --git a/site/content/docs/guide/secrets.fr.md b/site/content/docs/guide/secrets.fr.md index 692ecfd..9a6ebb5 100644 --- a/site/content/docs/guide/secrets.fr.md +++ b/site/content/docs/guide/secrets.fr.md @@ -55,28 +55,30 @@ Pendant l'étape de rendu d'une tâche de déploiement ou de mise à jour, le co 1. Lit les fichiers chiffrés depuis le répertoire du service dans le dépôt. 2. Déchiffre chaque fichier en utilisant la clé privée age. -3. Injecte le contenu déchiffré dans le bundle de service sous forme de `.composia-secret.env`. +3. Supprime le suffixe `.enc` et injecte le contenu déchiffré sous son nom d'exécution. Par exemple, `.secret.env.enc` devient `.secret.env`. Le bundle est transmis à l'agent via la connexion de rapport de l'agent. L'agent écrit le bundle sur disque et procède à `docker compose up`. L'environnement de secret déchiffré est disponible pour les services Compose sans que l'agent ne voie jamais la clé privée. +Les références de fichiers dans `composia-meta.yaml` utilisent les noms du dépôt, comme `.env.enc` ; Composia les convertit en `.env` avant l'utilisation à l'exécution. Les fichiers Compose, Caddyfiles et scripts utilisent directement le nom d'exécution `.env`. + ## Utilisation de la CLI Écrire un fichier de secret chiffré : ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` Lire et déchiffrer un fichier de secret : ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` Modifier un secret sur place (ouvre votre éditeur) : ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` Toutes les opérations d'écriture de secrets incluent une vérification de révision de base pour éviter les conflits avec des modifications concurrentes. @@ -93,6 +95,6 @@ Le contrôleur localise le service, résout le chemin du fichier relativement au ## Conditions d'erreur -- **Secrets non configurés** : `GetSecret` et `UpdateSecret` retournent `FailedPrecondition` lorsque `controller.secrets` n'est pas défini. -- **Fichier introuvable** : `GetSecret` retourne une réponse avec un contenu vide plutôt qu'une erreur lorsque le fichier n'existe pas. Cela permet aux clients de distinguer les fichiers manquants des échecs de déchiffrement. -- **Conflit de révision de base** : `UpdateSecret` utilise CAS (compare-and-swap) par rapport à HEAD du dépôt. Si le dépôt a changé depuis la dernière lecture, l'écriture échoue avec un conflit de révision. +- **Secrets non configurés** : les accès Repo aux fichiers `.enc` retournent `FailedPrecondition`. +- **Fichier introuvable** : `GetRepoFile` retourne `NotFound`. +- **Conflit de révision de base** : `UpdateRepoFile` protège HEAD avec CAS. diff --git a/site/content/docs/guide/secrets.ja.md b/site/content/docs/guide/secrets.ja.md index 4d5dc69..ca9c0e0 100644 --- a/site/content/docs/guide/secrets.ja.md +++ b/site/content/docs/guide/secrets.ja.md @@ -55,28 +55,30 @@ my-app/ 1. リポジトリ内のサービスディレクトリから暗号化ファイルを読み取ります。 2. age 秘密鍵を使用して各ファイルを復号します。 -3. 復号されたコンテンツを `.composia-secret.env` としてサービスバンドルに注入します。 +3. `.enc` 接尾辞を削除し、復号した内容を実行時ファイル名で注入します。たとえば `.secret.env.enc` は `.secret.env` になります。 バンドルはエージェントレポート接続を通じてエージェントにストリーミングされます。エージェントはバンドルをディスクに書き込み、`docker compose up` を続行します。復号されたシークレット環境は、エージェントが秘密鍵を見ることなく Compose サービスから利用可能になります。 +`composia-meta.yaml` のファイル参照には `.env.enc` のようなリポジトリ名を使用し、Composia が実行時の利用前に `.env` へ変換します。Compose ファイル、Caddyfile、スクリプト内では実行時名 `.env` を直接使用します。 + ## CLI の使用 暗号化シークレットファイルの書き込み: ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` シークレットファイルの読み取りと復号: ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` シークレットのインプレース編集(エディタが開きます): ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` すべてのシークレット書き込み操作には、同時変更との競合を防ぐためのベースリビジョンチェックが含まれます。 @@ -93,6 +95,6 @@ composia secret edit my-app .secret.env.enc ## エラー条件 -- **シークレット未設定**: `controller.secrets` が設定されていない場合、`GetSecret` と `UpdateSecret` は `FailedPrecondition` を返します。 -- **ファイルが見つからない**: `GetSecret` はファイルが存在しない場合、エラーではなく空のコンテンツレスポンスを返します。これによりクライアントはファイル不在と復号失敗を区別できます。 -- **ベースリビジョン競合**: `UpdateSecret` はリポジトリ HEAD に対して CAS(compare-and-swap)を使用します。最後の読み取り以降にリポジトリが変更された場合、書き込みはリビジョン競合で失敗します。 +- **シークレット未設定**: `.enc` への Repo アクセスは `FailedPrecondition` を返します。 +- **ファイルが見つからない**: `GetRepoFile` は `NotFound` を返します。 +- **ベースリビジョン競合**: `UpdateRepoFile` は CAS でリポジトリ HEAD を保護します。 diff --git a/site/content/docs/guide/secrets.md b/site/content/docs/guide/secrets.md index e4ccda1..d422ac8 100644 --- a/site/content/docs/guide/secrets.md +++ b/site/content/docs/guide/secrets.md @@ -47,7 +47,7 @@ my-app/ └── .secret.env.enc (encrypted with age) ``` -The controller encrypts plaintext on write and decrypts on read. The repository contains only ciphertext. Secrets never appear as plaintext in the repo, in task logs, or in transit to agents. +The repository filename, including `.enc`, is the canonical name used by the file tree, Repo API, CLI, and `composia-meta.yaml`. `GetRepoFile` returns plaintext for `.enc` files, and `UpdateRepoFile` accepts plaintext and encrypts it before committing. The repository contains only ciphertext. ## How secrets reach agents @@ -55,44 +55,49 @@ During the render step of a deploy or update task, the controller: 1. Reads encrypted files from the service directory in the repo. 2. Decrypts each file using the age private key. -3. Injects the decrypted content into the service bundle as `.composia-secret.env`. +3. Removes the `.enc` suffix and injects the decrypted content under its runtime filename. For example, `.secret.env.enc` becomes `.secret.env`. -The bundle is streamed to the agent over the agent report connection. The agent writes the bundle to disk and proceeds with `docker compose up`. The decrypted secret environment is available to the Compose services without the agent ever seeing the private key. +Encrypted repository files are omitted from the runtime bundle. The bundle is streamed to the agent over the agent report connection. The agent writes the decrypted runtime files to disk and proceeds with `docker compose up` without ever receiving the private key. + +File references in `composia-meta.yaml` use repository names such as `.env.enc`; Composia converts them to runtime names before use. References inside native files such as Compose files, Caddyfiles, and scripts use the runtime name `.env` because those files are interpreted by their native tools. ## CLI usage -Write an encrypted secret file: +Create or edit an encrypted file relative to a service directory: ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` -Read and decrypt a secret file: +Read and decrypt it through the Repo API: ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` -Edit a secret in place (opens your editor): +Replace it from a local plaintext file: ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` -All secret write operations include a base revision check to prevent conflicts with concurrent changes. +All Repo write operations include a base revision check to prevent conflicts with concurrent changes. ## File path rules -Secret file paths must: +Encrypted file paths must: -- Be relative to the service directory (not absolute). +- Be repository-relative when using Repo commands, or service-relative when using `service edit`. - Not contain path traversal sequences like `../`. -- Point to a file inside the service directory. +- Point to a file; `.enc` is reserved and cannot be used by a directory path segment. + +The `.enc` suffix is exact and case-sensitive. Directories cannot use it, and moves cannot cross between `.enc` and non-`.enc` names. -The controller locates the service, resolves the file path relative to the service directory, and operates on the repo file. +A file and its encrypted counterpart cannot coexist in the repository: for example, `.env` and `.env.enc` are mutually exclusive. ## Error conditions -- **Secrets not configured**: `GetSecret` and `UpdateSecret` return `FailedPrecondition` when `controller.secrets` is not set. -- **File not found**: `GetSecret` returns an empty content response rather than an error when the file does not exist. This lets clients distinguish between missing files and decryption failures. -- **Base revision conflict**: `UpdateSecret` uses CAS (compare-and-swap) against the repo HEAD. If the repo changed since the last read, the write fails with a revision conflict. +- **Secrets not configured**: Repo reads and writes for `.enc` files return `FailedPrecondition` when `controller.secrets` is not set. +- **File not found**: `GetRepoFile` returns `NotFound`. +- **Invalid ciphertext**: `GetRepoFile` returns `DataLoss` when an `.enc` file cannot be decrypted. +- **Base revision conflict**: `UpdateRepoFile` uses CAS (compare-and-swap) against the repo HEAD. If the repo changed since the last read, the write fails with a revision conflict. diff --git a/site/content/docs/guide/secrets.zh-hans.md b/site/content/docs/guide/secrets.zh-hans.md index 120158f..fe23ebf 100644 --- a/site/content/docs/guide/secrets.zh-hans.md +++ b/site/content/docs/guide/secrets.zh-hans.md @@ -55,28 +55,30 @@ my-app/ 1. 从仓库中的服务目录读取加密文件。 2. 使用 age 私钥解密每个文件。 -3. 将解密后的内容作为 `.composia-secret.env` 注入服务包。 +3. 去掉 `.enc` 后缀,并以运行时文件名注入解密内容。例如,`.secret.env.enc` 会变成 `.secret.env`。 服务包通过 agent 报告连接流式传输到 agent。Agent 将包写入磁盘并继续执行 `docker compose up`。解密后的密钥环境变量可供 Compose 服务使用,而 agent 永远不会看到私钥。 +`composia-meta.yaml` 中的文件引用使用仓库文件名,例如 `.env.enc`;Composia 在运行时使用前将其转换为 `.env`。Compose、Caddyfile、脚本等原生文件内部直接填写运行时文件名 `.env`。 + ## CLI 用法 写入加密的密钥文件: ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` 读取并解密密钥文件: ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` 就地编辑密钥(打开编辑器): ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` 所有密钥写入操作都包含基准版本检查,以防止与并发更改冲突。 @@ -93,6 +95,6 @@ composia secret edit my-app .secret.env.enc ## 错误情况 -- **密钥未配置**: 当 `controller.secrets` 未设置时,`GetSecret` 和 `UpdateSecret` 返回 `FailedPrecondition`。 -- **文件未找到**: 当文件不存在时,`GetSecret` 返回空内容响应而非错误。这使客户端能区分文件缺失和解密失败。 -- **基准版本冲突**: `UpdateSecret` 使用 CAS(比较并交换)机制针对仓库 HEAD。如果自上次读取以来仓库发生了变化,写入将因版本冲突而失败。 +- **密钥未配置**:当 `controller.secrets` 未设置时,Repo API 对 `.enc` 文件的读写返回 `FailedPrecondition`。 +- **文件未找到**:`GetRepoFile` 返回 `NotFound`。 +- **基准版本冲突**:`UpdateRepoFile` 使用 CAS(比较并交换)保护仓库 HEAD。 diff --git a/site/content/docs/guide/secrets.zh-hant.md b/site/content/docs/guide/secrets.zh-hant.md index 53e606a..3f26508 100644 --- a/site/content/docs/guide/secrets.zh-hant.md +++ b/site/content/docs/guide/secrets.zh-hant.md @@ -55,28 +55,30 @@ my-app/ 1. 從存放庫中的服務目錄讀取加密檔案。 2. 使用 age 私鑰解密每個檔案。 -3. 將解密後的內容作為 `.composia-secret.env` 注入服務包。 +3. 移除 `.enc` 副檔名,並以執行階段檔名注入解密內容。例如,`.secret.env.enc` 會成為 `.secret.env`。 服務包透過代理回報連線以串流方式傳送給代理。代理將服務包寫入磁碟,然後執行 `docker compose up`。解密的密鑰環境可供 Compose 服務使用,而代理從未見過私鑰。 +`composia-meta.yaml` 中的檔案引用使用存放庫檔名,例如 `.env.enc`;Composia 在執行階段使用前將其轉換為 `.env`。Compose、Caddyfile、指令碼等原生檔案內部直接填寫執行階段檔名 `.env`。 + ## CLI 使用 寫入加密密鑰檔案: ```bash -composia secret update my-app .secret.env.enc --file ./local-plain.env +composia service my-app edit .secret.env.enc ``` 讀取並解密一個密鑰檔案: ```bash -composia secret get my-app .secret.env.enc +composia repo get my-app/.secret.env.enc ``` 原地編輯密鑰(開啟您的編輯器): ```bash -composia secret edit my-app .secret.env.enc +composia repo update --file ./local-plain.env my-app/.secret.env.enc ``` 所有密鑰寫入操作都包含基礎修訂版本檢查,以防止與並行變更發生衝突。 @@ -93,6 +95,6 @@ composia secret edit my-app .secret.env.enc ## 錯誤狀況 -- **密鑰未設定**:當 `controller.secrets` 未設定時,`GetSecret` 和 `UpdateSecret` 返回 `FailedPrecondition`。 -- **檔案不存在**:當檔案不存在時,`GetSecret` 返回空的內容回應而非錯誤。這讓客戶端能夠區分遺失檔案和解密失敗。 -- **基礎修訂版本衝突**:`UpdateSecret` 對存放庫 HEAD 使用 CAS(比較並交換)機制。如果存放庫自上次讀取後已變更,寫入會因修訂版本衝突而失敗。 +- **密鑰未設定**:當 `controller.secrets` 未設定時,Repo API 對 `.enc` 檔案的讀寫返回 `FailedPrecondition`。 +- **檔案不存在**:`GetRepoFile` 返回 `NotFound`。 +- **基礎修訂版本衝突**:`UpdateRepoFile` 使用 CAS(比較並交換)保護存放庫 HEAD。 diff --git a/site/content/docs/guide/service.md b/site/content/docs/guide/service.md index cbdb5eb..53d8066 100644 --- a/site/content/docs/guide/service.md +++ b/site/content/docs/guide/service.md @@ -33,7 +33,7 @@ With the default behavior, Composia looks for `docker-compose.yaml` in the same | `migrate` | `object` | No | Migration-enabled protected data. | | `auto_deploy` | `bool` | No | Auto-deploy this service after repository changes. | -`compose_files` entries must be relative paths, must stay inside the service directory, and must not be duplicated. +`compose_files` entries must be relative paths, must stay inside the service directory, and must not be duplicated. All file references in `composia-meta.yaml` use repository filenames. An encrypted reference therefore includes `.enc`; Composia removes that suffix after decryption when the agent accesses the runtime file. References inside Compose files, Caddyfiles, scripts, and other native formats use the runtime filename without `.enc`. ## Infrastructure services diff --git a/test/controller_e2e/controller_test.go b/test/controller_e2e/controller_test.go index daf7aa3..785e2ec 100644 --- a/test/controller_e2e/controller_test.go +++ b/test/controller_e2e/controller_test.go @@ -39,7 +39,6 @@ type controllerClients struct { task controllerv1connect.TaskServiceClient backupQuery controllerv1connect.BackupQueryServiceClient backupCommand controllerv1connect.BackupCommandServiceClient - secret controllerv1connect.SecretServiceClient } func TestControllerE2EAuth(t *testing.T) { @@ -434,26 +433,26 @@ func TestControllerE2ESecrets(t *testing.T) { clients := newControllerClients() waitForController(t, clients) - const secretFilePath = ".secret.env.enc" + const secretFilePath = "host-service/.secret.env.enc" const initialContent = "TOKEN=controller-e2e-before\n" const updatedContent = "TOKEN=controller-e2e-after\n" - secret, err := clients.secret.GetSecret(ctx, connect.NewRequest(&controllerv1.GetSecretRequest{ServiceName: testServiceName, FilePath: secretFilePath})) + secret, err := clients.repoQuery.GetRepoFile(ctx, connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: secretFilePath})) if err != nil { t.Fatalf("get configured secret: %v", err) } - if secret.Msg.GetServiceName() != testServiceName || secret.Msg.GetFilePath() != secretFilePath || secret.Msg.GetContent() != initialContent { + if secret.Msg.GetPath() != secretFilePath || secret.Msg.GetContent() != initialContent { t.Fatalf("unexpected secret response: %+v", secret.Msg) } - _, err = clients.secret.GetSecret(ctx, connect.NewRequest(&controllerv1.GetSecretRequest{ServiceName: testServiceName, FilePath: "../outside.env.enc"})) + _, err = clients.repoQuery.GetRepoFile(ctx, connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: "../outside.env.enc"})) assertConnectCode(t, err, connect.CodeInvalidArgument) - _, err = clients.secret.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ServiceName: testServiceName, FilePath: secretFilePath, Content: updatedContent})) + _, err = clients.repoCommand.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: secretFilePath, Content: updatedContent})) assertConnectCode(t, err, connect.CodeInvalidArgument) head := repoHead(t, ctx, clients) - _, err = clients.secret.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ServiceName: testServiceName, FilePath: "/outside.env.enc", Content: updatedContent, BaseRevision: head.GetHeadRevision()})) + _, err = clients.repoCommand.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{Path: "/outside.env.enc", Content: updatedContent, BaseRevision: head.GetHeadRevision()})) assertConnectCode(t, err, connect.CodeInvalidArgument) workspace := fmt.Sprintf("controller-e2e-secret-base-%d", time.Now().UnixNano()) @@ -467,18 +466,16 @@ func TestControllerE2ESecrets(t *testing.T) { } defer cleanupRepoPath(t, ctx, clients, workspace) - _, err = clients.secret.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ - ServiceName: testServiceName, - FilePath: secretFilePath, + _, err = clients.repoCommand.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{ + Path: secretFilePath, Content: updatedContent, BaseRevision: head.GetHeadRevision(), CommitMessage: "Stale controller e2e secret update", })) assertConnectCode(t, err, connect.CodeFailedPrecondition) - updated, err := clients.secret.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ - ServiceName: testServiceName, - FilePath: secretFilePath, + updated, err := clients.repoCommand.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{ + Path: secretFilePath, Content: updatedContent, BaseRevision: created.Msg.GetCommitId(), CommitMessage: "Update controller e2e secret", @@ -491,9 +488,8 @@ func TestControllerE2ESecrets(t *testing.T) { } defer func() { head := repoHead(t, ctx, clients) - _, err := clients.secret.UpdateSecret(ctx, connect.NewRequest(&controllerv1.UpdateSecretRequest{ - ServiceName: testServiceName, - FilePath: secretFilePath, + _, err := clients.repoCommand.UpdateRepoFile(ctx, connect.NewRequest(&controllerv1.UpdateRepoFileRequest{ + Path: secretFilePath, Content: initialContent, BaseRevision: head.GetHeadRevision(), CommitMessage: "Restore controller e2e secret", @@ -503,21 +499,13 @@ func TestControllerE2ESecrets(t *testing.T) { } }() - secret, err = clients.secret.GetSecret(ctx, connect.NewRequest(&controllerv1.GetSecretRequest{ServiceName: testServiceName, FilePath: secretFilePath})) + secret, err = clients.repoQuery.GetRepoFile(ctx, connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: secretFilePath})) if err != nil { t.Fatalf("get updated secret: %v", err) } if secret.Msg.GetContent() != updatedContent { t.Fatalf("updated secret content = %q, want %q", secret.Msg.GetContent(), updatedContent) } - - file, err := clients.repoQuery.GetRepoFile(ctx, connect.NewRequest(&controllerv1.GetRepoFileRequest{Path: "host-service/" + secretFilePath})) - if err != nil { - t.Fatalf("get encrypted secret file: %v", err) - } - if strings.Contains(file.Msg.GetContent(), updatedContent) { - t.Fatalf("expected encrypted secret file not to contain plaintext") - } } func TestControllerE2EFeatureContracts(t *testing.T) { @@ -876,7 +864,6 @@ func newControllerClients() controllerClients { task: controllerv1connect.NewTaskServiceClient(http.DefaultClient, baseURL, opts...), backupQuery: controllerv1connect.NewBackupQueryServiceClient(http.DefaultClient, baseURL, opts...), backupCommand: controllerv1connect.NewBackupCommandServiceClient(http.DefaultClient, baseURL, opts...), - secret: controllerv1connect.NewSecretServiceClient(http.DefaultClient, baseURL, opts...), } } diff --git a/web/src/lib/gen/proto/composia/agent/v1/agent_pb.ts b/web/src/lib/gen/proto/composia/agent/v1/agent_pb.ts index 261efa7..3adb8ce 100644 --- a/web/src/lib/gen/proto/composia/agent/v1/agent_pb.ts +++ b/web/src/lib/gen/proto/composia/agent/v1/agent_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/agent/v1/agent.proto (package composia.agent.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/backup_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/backup_pb.ts index 0d5f63d..95ad754 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/backup_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/backup_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/backup.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/container_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/container_pb.ts index f445b3b..9364ecc 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/container_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/container_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/container.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/node_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/node_pb.ts index 75966b3..38d3885 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/node_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/node_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/node.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/repo_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/repo_pb.ts index d67000a..cc7d1b2 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/repo_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/repo_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/repo.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ @@ -195,7 +195,7 @@ export const GetRepoFileRequestSchema: GenMessage = /*@__PUR messageDesc(file_proto_composia_controller_v1_repo, 5); /** - * GetRepoFileResponse returns a repo file and its text content. + * GetRepoFileResponse returns a repo file and its API-visible text content. * * @generated from message composia.controller.v1.GetRepoFileResponse */ @@ -208,14 +208,14 @@ export type GetRepoFileResponse = Message<"composia.controller.v1.GetRepoFileRes path: string; /** - * content is the file content as text. + * content is decrypted plaintext for .enc files and stored text for other files. * * @generated from field: string content = 2; */ content: string; /** - * size is the file size in bytes. + * size is the byte length of content. ListRepoFiles reports stored sizes instead. * * @generated from field: int64 size = 3; */ @@ -399,7 +399,7 @@ export type UpdateRepoFileRequest = Message<"composia.controller.v1.UpdateRepoFi path: string; /** - * content is the full replacement file content. + * content is plaintext for .enc files and stored text for other files. * * @generated from field: string content = 2; */ @@ -672,7 +672,7 @@ export const RepoQueryService: GenService<{ output: typeof ListRepoFilesResponseSchema; }, /** - * GetRepoFile returns the content of one repo file. + * GetRepoFile returns plaintext for .enc files and stored content for other files. * * @generated from rpc composia.controller.v1.RepoQueryService.GetRepoFile */ @@ -711,7 +711,7 @@ export const RepoQueryService: GenService<{ */ export const RepoCommandService: GenService<{ /** - * UpdateRepoFile writes one file, creates a commit, and reports sync state. + * UpdateRepoFile encrypts plaintext for .enc files, writes one file, creates a commit, and reports sync state. * * @generated from rpc composia.controller.v1.RepoCommandService.UpdateRepoFile */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/secret_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/secret_pb.ts deleted file mode 100644 index 5b324e3..0000000 --- a/web/src/lib/gen/proto/composia/controller/v1/secret_pb.ts +++ /dev/null @@ -1,155 +0,0 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" -// @generated from file proto/composia/controller/v1/secret.proto (package composia.controller.v1, syntax proto3) -/* eslint-disable */ - -import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2"; -import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2"; -import type { RepoWriteResultSchema } from "./repo_pb"; -import { file_proto_composia_controller_v1_repo } from "./repo_pb"; -import type { Message } from "@bufbuild/protobuf"; - -/** - * Describes the file proto/composia/controller/v1/secret.proto. - */ -export const file_proto_composia_controller_v1_secret: GenFile = /*@__PURE__*/ - fileDesc("Cilwcm90by9jb21wb3NpYS9jb250cm9sbGVyL3YxL3NlY3JldC5wcm90bxIWY29tcG9zaWEuY29udHJvbGxlci52MSJQChBHZXRTZWNyZXRSZXF1ZXN0EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIRCglmaWxlX3BhdGgYAiABKAkSEwoLc2VydmljZV9kaXIYAyABKAkiTQoRR2V0U2VjcmV0UmVzcG9uc2USFAoMc2VydmljZV9uYW1lGAEgASgJEhEKCWZpbGVfcGF0aBgCIAEoCRIPCgdjb250ZW50GAMgASgJIpMBChNVcGRhdGVTZWNyZXRSZXF1ZXN0EhQKDHNlcnZpY2VfbmFtZRgBIAEoCRIRCglmaWxlX3BhdGgYAiABKAkSDwoHY29udGVudBgDIAEoCRIVCg1iYXNlX3JldmlzaW9uGAQgASgJEhYKDmNvbW1pdF9tZXNzYWdlGAUgASgJEhMKC3NlcnZpY2VfZGlyGAYgASgJMtcBCg1TZWNyZXRTZXJ2aWNlEmAKCUdldFNlY3JldBIoLmNvbXBvc2lhLmNvbnRyb2xsZXIudjEuR2V0U2VjcmV0UmVxdWVzdBopLmNvbXBvc2lhLmNvbnRyb2xsZXIudjEuR2V0U2VjcmV0UmVzcG9uc2USZAoMVXBkYXRlU2VjcmV0EisuY29tcG9zaWEuY29udHJvbGxlci52MS5VcGRhdGVTZWNyZXRSZXF1ZXN0GicuY29tcG9zaWEuY29udHJvbGxlci52MS5SZXBvV3JpdGVSZXN1bHRCWFpWZm9yZ2Vqby5hbGV4bWEudG9wL2FsZXhtYTIzMy9jb21wb3NpYS9nZW4vZ28vcHJvdG8vY29tcG9zaWEvY29udHJvbGxlci92MTtjb250cm9sbGVydjFiBnByb3RvMw", [file_proto_composia_controller_v1_repo]); - -/** - * GetSecretRequest identifies one decrypted secret file for a service. - * - * @generated from message composia.controller.v1.GetSecretRequest - */ -export type GetSecretRequest = Message<"composia.controller.v1.GetSecretRequest"> & { - /** - * @generated from field: string service_name = 1; - */ - serviceName: string; - - /** - * @generated from field: string file_path = 2; - */ - filePath: string; - - /** - * @generated from field: string service_dir = 3; - */ - serviceDir: string; -}; - -/** - * Describes the message composia.controller.v1.GetSecretRequest. - * Use `create(GetSecretRequestSchema)` to create a new message. - */ -export const GetSecretRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_proto_composia_controller_v1_secret, 0); - -/** - * GetSecretResponse returns one decrypted secret file. - * - * @generated from message composia.controller.v1.GetSecretResponse - */ -export type GetSecretResponse = Message<"composia.controller.v1.GetSecretResponse"> & { - /** - * @generated from field: string service_name = 1; - */ - serviceName: string; - - /** - * @generated from field: string file_path = 2; - */ - filePath: string; - - /** - * @generated from field: string content = 3; - */ - content: string; -}; - -/** - * Describes the message composia.controller.v1.GetSecretResponse. - * Use `create(GetSecretResponseSchema)` to create a new message. - */ -export const GetSecretResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_proto_composia_controller_v1_secret, 1); - -/** - * UpdateSecretRequest writes one decrypted secret file for a service. - * - * @generated from message composia.controller.v1.UpdateSecretRequest - */ -export type UpdateSecretRequest = Message<"composia.controller.v1.UpdateSecretRequest"> & { - /** - * @generated from field: string service_name = 1; - */ - serviceName: string; - - /** - * file_path is the repo-relative secret file path for the service. - * - * @generated from field: string file_path = 2; - */ - filePath: string; - - /** - * content is the full decrypted secret file content to store. - * - * @generated from field: string content = 3; - */ - content: string; - - /** - * base_revision protects against writing on top of an unexpected HEAD. - * - * @generated from field: string base_revision = 4; - */ - baseRevision: string; - - /** - * commit_message is used for the generated Git commit. - * - * @generated from field: string commit_message = 5; - */ - commitMessage: string; - - /** - * @generated from field: string service_dir = 6; - */ - serviceDir: string; -}; - -/** - * Describes the message composia.controller.v1.UpdateSecretRequest. - * Use `create(UpdateSecretRequestSchema)` to create a new message. - */ -export const UpdateSecretRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_proto_composia_controller_v1_secret, 2); - -/** - * SecretService reads and updates encrypted secret files stored in the repo. - * - * @generated from service composia.controller.v1.SecretService - */ -export const SecretService: GenService<{ - /** - * GetSecret returns the decrypted content for one service secret file. - * - * @generated from rpc composia.controller.v1.SecretService.GetSecret - */ - getSecret: { - methodKind: "unary"; - input: typeof GetSecretRequestSchema; - output: typeof GetSecretResponseSchema; - }, - /** - * UpdateSecret writes one secret file and reports the resulting repo sync state. - * - * @generated from rpc composia.controller.v1.SecretService.UpdateSecret - */ - updateSecret: { - methodKind: "unary"; - input: typeof UpdateSecretRequestSchema; - output: typeof RepoWriteResultSchema; - }, -}> = /*@__PURE__*/ - serviceDesc(file_proto_composia_controller_v1_secret, 0); - diff --git a/web/src/lib/gen/proto/composia/controller/v1/service_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/service_pb.ts index f2cd535..f88da1a 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/service_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/service_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/service.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/system_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/system_pb.ts index 576b7da..524d368 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/system_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/system_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/system.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/gen/proto/composia/controller/v1/task_pb.ts b/web/src/lib/gen/proto/composia/controller/v1/task_pb.ts index 390f41e..c263331 100644 --- a/web/src/lib/gen/proto/composia/controller/v1/task_pb.ts +++ b/web/src/lib/gen/proto/composia/controller/v1/task_pb.ts @@ -1,4 +1,4 @@ -// @generated by protoc-gen-es v2.12.1 with parameter "target=ts" +// @generated by protoc-gen-es v2.13.0 with parameter "target=ts" // @generated from file proto/composia/controller/v1/task.proto (package composia.controller.v1, syntax proto3) /* eslint-disable */ diff --git a/web/src/lib/server/controller.ts b/web/src/lib/server/controller.ts index 6eb2f2f..0617aa9 100644 --- a/web/src/lib/server/controller.ts +++ b/web/src/lib/server/controller.ts @@ -337,12 +337,6 @@ export type RepoCommitSummary = { committedAt: string; }; -export type SecretEnv = { - serviceName: string; - filePath: string; - content: string; -}; - export type DashboardData = { system: SystemStatus; services: ServiceSummary[]; @@ -1376,35 +1370,6 @@ export async function syncRepo(): Promise { ); } -export async function loadSecret( - serviceDir: string, - filePath: string, -): Promise { - const config = requireControllerConfig(); - return rpcCall( - config.baseUrl, - config.token, - controllerProcedure("/composia.controller.v1.SecretService/GetSecret"), - { serviceDir, filePath }, - ); -} - -export async function updateSecret( - serviceDir: string, - filePath: string, - content: string, - baseRevision: string, - commitMessage = "", -): Promise { - const config = requireControllerConfig(); - return rpcCall( - config.baseUrl, - config.token, - controllerProcedure("/composia.controller.v1.SecretService/UpdateSecret"), - { serviceDir, filePath, content, baseRevision, commitMessage }, - ); -} - export async function loadServiceInstances( serviceName: string, ): Promise { diff --git a/web/src/lib/server/service-workspace.ts b/web/src/lib/server/service-workspace.ts index 4016b5b..96318a6 100644 --- a/web/src/lib/server/service-workspace.ts +++ b/web/src/lib/server/service-workspace.ts @@ -7,10 +7,8 @@ import { loadRepoEntries, loadRepoFile, loadSystemCapabilities, - loadSecret, moveRepoPath, updateRepoFile, - updateSecret, } from "$lib/server/controller"; import { isEncryptedFilePath, @@ -38,7 +36,6 @@ export async function loadServiceWorkspaceFile( relativePath: string, ): Promise { const normalized = normalizeServiceRelativePath(relativePath); - let content: string; if (isEncryptedFilePath(normalized)) { const capabilities = await loadSystemCapabilities(); if (!capabilities.global.secrets.enabled) { @@ -47,15 +44,11 @@ export async function loadServiceWorkspaceFile( capabilities.global.secrets.reasonCode || "missing_secrets_config", ); } - - const secret = await loadSecret(serviceDir, normalized); - content = secret.content ?? ""; - } else { - const file = await loadRepoFile( - repoPathForServicePath(serviceDir, normalized), - ); - content = file.content ?? ""; } + const file = await loadRepoFile( + repoPathForServicePath(serviceDir, normalized), + ); + const content = file.content ?? ""; return { path: normalized, content, @@ -70,7 +63,6 @@ export async function saveServiceWorkspaceFile( baseRevision: string, ): Promise<{ file: WorkspaceFile; write: RepoWriteResult }> { const normalized = normalizeServiceRelativePath(relativePath); - let write: RepoWriteResult; if (isEncryptedFilePath(normalized)) { const capabilities = await loadSystemCapabilities(); if (!capabilities.global.secrets.enabled) { @@ -80,15 +72,12 @@ export async function saveServiceWorkspaceFile( ] ?? "Encrypted file is currently unavailable.", ); } - - write = await updateSecret(serviceDir, normalized, content, baseRevision); - } else { - write = await updateRepoFile( - repoPathForServicePath(serviceDir, normalized), - content, - baseRevision, - ); } + const write = await updateRepoFile( + repoPathForServicePath(serviceDir, normalized), + content, + baseRevision, + ); return { file: { path: normalized, diff --git a/web/src/lib/service-workspace.ts b/web/src/lib/service-workspace.ts index f7c130b..ef9be30 100644 --- a/web/src/lib/service-workspace.ts +++ b/web/src/lib/service-workspace.ts @@ -18,7 +18,7 @@ export type WorkspaceFile = { }; export function isEncryptedFilePath(path: string) { - return path.toLowerCase().endsWith(".enc"); + return path.endsWith(".enc"); } export function encryptedFileSourcePath(path: string) { -- 2.54.0