diff --git a/.gitignore b/.gitignore index 177b126..a6ef824 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ -/bazel-bin -/bazel-out -/bazel-talkie -/bazel-testlogs +/bazel-* diff --git a/defs.bzl b/defs.bzl index 22dec8f..aafafd6 100644 --- a/defs.bzl +++ b/defs.bzl @@ -28,12 +28,14 @@ load("//generator/renderer:defs.bzl", "render") load("//generator/json/bazel_stamp:defs.bzl", "bazel_stamp_to_json") DEFAULT_VERSION_WORKSPACE_STATUS_KEY = "STABLE_TALKIE_RELEASE_VERSION" +SECRETS_MOUNT_PATH = "/var/talkie/secrets" def talkie_service( name, base_image, service_definition, service_implementation, + secrets = [], version_workspace_status_key = DEFAULT_VERSION_WORKSPACE_STATUS_KEY, talks_to = [], container_repository = "", @@ -47,6 +49,8 @@ def talkie_service( base_image: The OCI base image used for the Talkie server. service_definition: The go_library containing the gRPC service definitions. service_implementation: The go_library containing the gRPC service implementation. + secrets: A list of secrets. E.g. 'redis.username' and 'redis.password' would become + '{"redis":{"username", "password"}}' under Helm values. version_workspace_status_key: The key used to extract the release version from the Bazel workspace status. talks_to: A list of other talkie_service targets this Talkie service can talk to. container_repository: A container repository to prefix the service images. @@ -74,6 +78,7 @@ def talkie_service( "@aspect_talkie//service", "@aspect_talkie//service/client", "@aspect_talkie//service/logger", + "@aspect_talkie//service/secrets", "@com_github_avast_retry_go_v4//:retry-go", "@com_github_sirupsen_logrus//:logrus", "@org_golang_google_grpc//:grpc", @@ -175,6 +180,7 @@ def talkie_service( image = image_target + ".tar", image_name = image_name, version_workspace_status_key = version_workspace_status_key, + secrets = secrets, server = server_binary_target, talks_to = talks_to, visibility = visibility, @@ -354,10 +360,12 @@ TalkieServiceInfo = provider( "client_source": "The client .go source file for connecting to the service.", "enable_grpc_gateway": "If a grpc gateway should be created for this service.", "image_name": "The image name (does not include the image repository or image tag).", - "version_workspace_status_key": "The key used to extract the release version from the Bazel workspace status.", "image_tar": "The image tarballs.", + "secrets": "A list of secrets. E.g. 'redis.username' and 'redis.password' would become" + + "'{\"redis\":{\"username\", \"password\"}}' under Helm values.", "service_name": "The service name.", "talks_to": "A list of Talkie client targets this service is allowed to communicate.", + "version_workspace_status_key": "The key used to extract the release version from the Bazel workspace status.", }, ) @@ -376,6 +384,8 @@ def _talkie_service_impl(ctx): runfiles = server_default_info.default_runfiles, ) + secrets = sorted(ctx.attr.secrets) + # In addition to the DefaultInfo, return a TalkieServiceInfo that is used by # the deployment rules and any other rules that may want more information # about this Talkie service. @@ -383,10 +393,14 @@ def _talkie_service_impl(ctx): client_source = ctx.file.client_source, enable_grpc_gateway = ctx.attr.enable_grpc_gateway, image_name = ctx.attr.image_name, - version_workspace_status_key = ctx.attr.version_workspace_status_key, image_tar = ctx.file.image, + secrets = struct( + parsed = _parse_secrets(secrets), + unparsed = secrets, + ), service_name = ctx.attr.name, talks_to = [s[TalkieServiceClientInfo] for s in ctx.attr.talks_to], + version_workspace_status_key = ctx.attr.version_workspace_status_key, ) return [ @@ -394,6 +408,38 @@ def _talkie_service_impl(ctx): talkie_service_info, ] +def _parse_secrets(secrets): + parsed = {} + + # Secrets must be sorted so that the 'same prefix' validation is correct. If + # the secrets are not sorted, then 'if index == len(parts)-1:' would always + # override parts of the secrets mapping. A check could happen there, but + # tracking which secret it collides becomes hard to be able to feedback the + # user more accurately. + for secret in sorted(secrets): + parts = secret.split(".") + current = parsed + for index, part in enumerate(parts): + if not _validate_secret_part(part): + fail("the secret '{}' must not be empty and only contain letters, numbers or underscores".format(secret)) + if index == len(parts) - 1: + current[part] = None + break + if not part in current: + current[part] = {} + elif current[part] == None: + fail("could not parse '{}' - the secret '{}' was already set".format(secret, ".".join(parts[:index + 1]))) + current = current[part] + return parsed + +def _validate_secret_part(part): + if part == "": + return False + for c in range(0, len(part)): + if not part[c] in "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_": + return False + return True + _talkie_service = rule( _talkie_service_impl, attrs = { @@ -420,6 +466,12 @@ _talkie_service = rule( doc = "The key used to extract the release version from the Bazel workspace status.", mandatory = True, ), + "secrets": attr.string_list( + default = [], + doc = "A list of secrets. E.g. 'redis.username' and 'redis.password' would become" + + "'{\"redis\":{\"username\", \"password\"}}' under Helm values.", + mandatory = False, + ), "server": attr.label( doc = "The go_binary for the Talkie server.", mandatory = True, @@ -703,16 +755,18 @@ def _validate_services(services): def _deployment_attributes(name, container_registry, services): return dict({ - "name": name, "container_registry": container_registry, + "name": name, + "secrets_mount_path": SECRETS_MOUNT_PATH, "services": [ struct( enable_grpc_gateway = service[TalkieServiceInfo].enable_grpc_gateway, - image_tar = service[TalkieServiceInfo].image_tar.short_path, image_name = service[TalkieServiceInfo].image_name, - version_workspace_status_key = service[TalkieServiceInfo].version_workspace_status_key, + image_tar = service[TalkieServiceInfo].image_tar.short_path, + secrets = service[TalkieServiceInfo].secrets, service_name = service[TalkieServiceInfo].service_name, talks_to = service[TalkieServiceInfo].talks_to, + version_workspace_status_key = service[TalkieServiceInfo].version_workspace_status_key, ) for service in services ], diff --git a/examples/.gitignore b/examples/.gitignore new file mode 100644 index 0000000..a6ef824 --- /dev/null +++ b/examples/.gitignore @@ -0,0 +1 @@ +/bazel-* diff --git a/examples/BUILD.bazel b/examples/BUILD.bazel index a8bb763..23c658f 100644 --- a/examples/BUILD.bazel +++ b/examples/BUILD.bazel @@ -5,6 +5,7 @@ load("@bazel_gazelle//:def.bzl", "gazelle") # gazelle:exclude talkie # gazelle:prefix github.com/aspect-build/talkie/examples # gazelle:resolve go github.com/aspect-build/talkie/service @aspect_talkie//service +# gazelle:resolve go github.com/aspect-build/talkie/service/secrets @aspect_talkie//service/secrets gazelle(name = "gazelle") gazelle( diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..f26810b --- /dev/null +++ b/examples/README.md @@ -0,0 +1,17 @@ +# Examples + +# Using kind (Kubernetes-in-Docker) + +With a kind cluster created in your machine, build and load the images with: + +``` +bazel build //:examples && bazel run //:examples.kind_load_images +``` + +Then install the helm chart, replacing `` with a URL (it won't try to connect), then +check in the logs that the service printed the url to the terminal: + +``` +bazel run @sh_helm_helm_v3//cmd/helm -- install examples "$(pwd)/bazel-bin/examples.tgz" \ + --set talkie_services.helloworld.secrets.redis.url= +``` diff --git a/examples/helloworld/BUILD.bazel b/examples/helloworld/BUILD.bazel index ac285b0..52b1890 100644 --- a/examples/helloworld/BUILD.bazel +++ b/examples/helloworld/BUILD.bazel @@ -3,6 +3,9 @@ load("@aspect_talkie//:defs.bzl", "talkie_service") talkie_service( name = "helloworld", base_image = "@distroless_base_debian11//image", + # This becomes a required value to be set in the Helm chart. The user-provided secret will be + # accessible at runtime using the github.com/aspect-build/talkie/service/secrets package. + secrets = ["redis.url"], service_definition = "//helloworld/protos", service_implementation = "//helloworld/service", visibility = ["//visibility:public"], diff --git a/examples/helloworld/service/BUILD.bazel b/examples/helloworld/service/BUILD.bazel index 705583f..0714dab 100644 --- a/examples/helloworld/service/BUILD.bazel +++ b/examples/helloworld/service/BUILD.bazel @@ -8,5 +8,6 @@ go_library( deps = [ "//helloworld/protos", "@aspect_talkie//service", + "@aspect_talkie//service/secrets", ], ) diff --git a/examples/helloworld/service/helloworld.go b/examples/helloworld/service/helloworld.go index ae57d6a..ad41a2b 100644 --- a/examples/helloworld/service/helloworld.go +++ b/examples/helloworld/service/helloworld.go @@ -18,8 +18,10 @@ package service import ( "context" + "fmt" "github.com/aspect-build/talkie/service" + "github.com/aspect-build/talkie/service/secrets" pb "github.com/aspect-build/talkie/examples/helloworld/protos" ) @@ -33,6 +35,11 @@ type Greeter struct { // method, as service.Talkie provides an empty implementation of this. func (s *Greeter) BeforeStart() error { s.Log.Infof("called BeforeStart") + redisURL, err := secrets.Get("redis.url") + if err != nil { + return fmt.Errorf("failed to initialize Greeter: %w", err) + } + s.Log.Infof(redisURL) return nil } diff --git a/examples/helloworld/tests/BUILD.bazel b/examples/helloworld/tests/BUILD.bazel index f032a32..32b5534 100644 --- a/examples/helloworld/tests/BUILD.bazel +++ b/examples/helloworld/tests/BUILD.bazel @@ -1,12 +1,20 @@ load("@io_bazel_rules_go//go:def.bzl", "go_test") +filegroup( + name = "testdata", + srcs = glob(["testdata/**"]), +) + go_test( name = "tests_test", srcs = [ "smoke_suite_test.go", "smoke_test.go", ], - data = ["//helloworld"], + data = [ + ":testdata", + "//helloworld", + ], deps = [ "//helloworld/client", "//helloworld/protos", diff --git a/examples/helloworld/tests/smoke_test.go b/examples/helloworld/tests/smoke_test.go index db58923..3b1d763 100644 --- a/examples/helloworld/tests/smoke_test.go +++ b/examples/helloworld/tests/smoke_test.go @@ -21,6 +21,7 @@ import ( "fmt" "os" "os/exec" + "path" "strings" "time" @@ -48,9 +49,11 @@ var _ = BeforeSuite(func() { _, err = os.Stat(server) Expect(err).ToNot(HaveOccurred()) + secretsDir := path.Join(os.Getenv("TEST_SRCDIR"), "aspect_talkie_examples", "helloworld", "tests", "testdata", "secrets") + port++ address = fmt.Sprintf("127.0.0.1:%d", port) - cmd = exec.Command(server, "-grpc-address", address) + cmd = exec.Command(server, "-secrets-dir", secretsDir, "-grpc-address", address) cmd.Stdout = &stdout cmd.Stderr = &stderr err = cmd.Start() @@ -67,6 +70,7 @@ var _ = AfterSuite(func() { Expect(stdout.String()).To(BeEmpty()) stderrStr := stderr.String() Expect(stderrStr).To(ContainSubstring("called BeforeStart")) + Expect(stderrStr).To(ContainSubstring("redis://127.0.0.1:6379")) Expect(stderrStr).To(ContainSubstring("called SayHello: John")) Expect(stderrStr).To(ContainSubstring("called BeforeExit")) }) diff --git a/examples/helloworld/tests/testdata/secrets/redis.url b/examples/helloworld/tests/testdata/secrets/redis.url new file mode 100644 index 0000000..f85bc52 --- /dev/null +++ b/examples/helloworld/tests/testdata/secrets/redis.url @@ -0,0 +1 @@ +redis://127.0.0.1:6379 \ No newline at end of file diff --git a/generator/deployment/helm/chart/templates/talkie_services.yaml b/generator/deployment/helm/chart/templates/talkie_services.yaml index 8912eec..7050c96 100644 --- a/generator/deployment/helm/chart/templates/talkie_services.yaml +++ b/generator/deployment/helm/chart/templates/talkie_services.yaml @@ -16,32 +16,33 @@ */ -%> <%- range $service := .services %> -{{- with $service_config := (get $.Values.talkie_services <% $service.service_name | kebabcase | quote %>) }} -{{- with $service_name := ($service_config.nameOverride | default <% $service.service_name | kebabcase | quote %>) }} +<%- with $service_name := ($service.service_name | snakecase) %> +{{- with $service_config := (get $.Values.talkie_services <% $service_name | quote %>) }} +{{- with $k8s_service_name := ($service_config.nameOverride | default <% $service_name | kebabcase | quote %>) }} --- apiVersion: apps/v1 kind: Deployment metadata: - name: {{ $service_name | quote }} + name: {{ $k8s_service_name | quote }} namespace: {{ $.Release.Namespace }} labels: - {{- include "chart.labels" (merge (dict "Name" ($service_name | quote)) $) | nindent 4 }} + {{- include "chart.labels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} spec: replicas: {{ $service_config.replicas }} selector: matchLabels: - {{- include "chart.selectorLabels" (merge (dict "Name" ($service_name | quote)) $) | nindent 6 }} + {{- include "chart.selectorLabels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 6 }} template: metadata: labels: - {{- include "chart.labels" (merge (dict "Name" ($service_name | quote)) $) | nindent 8 }} + {{- include "chart.labels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 8 }} spec: {{- with $service_config.image.pullSecrets }} imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} containers: - - name: {{ $service_name | quote }} + - name: {{ $k8s_service_name | quote }} {{- if $service_config.image.registry }} image: {{ printf "%s/%s:%s" $service_config.image.registry $service_config.image.repository $service_config.image.tag | quote }} {{- else }} @@ -49,6 +50,7 @@ spec: {{- end }} imagePullPolicy: {{ $service_config.image.pullPolicy }} args: + - <% printf "-secrets-dir=%s" $.secrets_mount_path | quote %> - "-grpc-address=0.0.0.0:50051" <%- if $service.enable_grpc_gateway %> - "-http-address=0.0.0.0:8090" @@ -82,21 +84,43 @@ spec: - ALL readOnlyRootFilesystem: true runAsNonRoot: true + volumeMounts: + - name: {{ printf "%s-secrets" $k8s_service_name | quote }} + mountPath: <% $.secrets_mount_path | quote %> + readOnly: true securityContext: fsGroup: 1000 runAsGroup: 1000 runAsUser: 1000 + volumes: + - name: {{ printf "%s-secrets" $k8s_service_name | quote }} + secret: + secretName: {{ printf "%s-secrets" $k8s_service_name | quote }} + optional: false +--- +apiVersion: v1 +kind: Secret +metadata: + name: {{ printf "%s-secrets" $k8s_service_name | quote }} + namespace: {{ $.Release.Namespace }} + labels: + {{- include "chart.labels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} +type: Opaque +data: + <%- range $secret := $service.secrets.unparsed %> + <% $secret | quote %>: {{ required "the secret <% $secret | squote %> must be set for the service <% $service_name | squote %>" $service_config.secrets.<% $secret %> | b64enc | quote }} + <%- end %> --- apiVersion: v1 kind: Service metadata: - name: {{ $service_name | quote }} + name: {{ $k8s_service_name | quote }} namespace: {{ $.Release.Namespace }} labels: - {{- include "chart.labels" (merge (dict "Name" ($service_name | quote)) $) | nindent 4 }} + {{- include "chart.labels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} spec: selector: - {{- include "chart.selectorLabels" (merge (dict "Name" ($service_name | quote)) $) | nindent 4 }} + {{- include "chart.selectorLabels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} clusterIP: None ports: - name: grpc @@ -114,13 +138,13 @@ spec: apiVersion: v1 kind: Service metadata: - name: {{ printf "%s-public" $service_name | quote }} + name: {{ printf "%s-public" $k8s_service_name | quote }} namespace: {{ $.Release.Namespace }} labels: - {{- include "chart.labels" (merge (dict "Name" ($service_name | quote)) $) | nindent 4 }} + {{- include "chart.labels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} spec: selector: - {{- include "chart.selectorLabels" (merge (dict "Name" ($service_name | quote)) $) | nindent 4 }} + {{- include "chart.selectorLabels" (merge (dict "Name" ($k8s_service_name | quote)) $) | nindent 4 }} type: LoadBalancer {{- if $service_config.public.clusterIP }} clusterIP: {{ $service_config.public.clusterIP | quote }} @@ -143,3 +167,4 @@ spec: {{- end }} {{- end }} <%- end %> +<%- end %> diff --git a/generator/deployment/helm/chart/values.yaml b/generator/deployment/helm/chart/values.yaml index 3651952..9dce66d 100644 --- a/generator/deployment/helm/chart/values.yaml +++ b/generator/deployment/helm/chart/values.yaml @@ -24,7 +24,7 @@ talkie_services: <%- /* TODO(f0rmiga): add tolerations. */%> <%- /* TODO(f0rmiga): add possibility of node selector. */%> <%- range $service := .services %> - <% $service.service_name | kebabcase %>: + <% $service.service_name | snakecase %>: image: pullPolicy: IfNotPresent pullSecrets: [] @@ -47,6 +47,8 @@ talkie_services: loadBalancerIP: ~ # How many pod replicas this Talkie Service should have. replicas: 3 + # These are the secrets required by the service. + secrets: <% $service.secrets.parsed | toYaml | nindent 6 %> # By default, a k8s ServiceAccount is created for each Talkie Service. If 'serviceAccountName' is # set, then it will be used instead and no ServiceAccount will be created automatically. # TODO(f0rmiga): create a service account by default and implement this logic. diff --git a/generator/entrypoints/server_tmpl b/generator/entrypoints/server_tmpl index dc6d131..23c0487 100644 --- a/generator/entrypoints/server_tmpl +++ b/generator/entrypoints/server_tmpl @@ -49,6 +49,7 @@ import ( "github.com/aspect-build/talkie/service/client" "github.com/aspect-build/talkie/service/logger" + "github.com/aspect-build/talkie/service/secrets" "github.com/aspect-build/talkie/service" pb "{{ .service_definition }}" @@ -62,6 +63,7 @@ import ( {{- end}} ) +var secretsDir string var grpcAddressFlag string var logLevelFlag string var httpAddressFlag string @@ -69,6 +71,7 @@ var httpAddressFlag string var log logger.Logger func init() { + flag.StringVar(&secretsDir, "secrets-dir", "", "The root directory containing the secrets files.") flag.StringVar(&grpcAddressFlag, "grpc-address", "0.0.0.0:50051", "The address for the gRPC server, including port, to listen.") flag.StringVar(&logLevelFlag, "log-level", "info", "The log level (panic, fatal, error, warn, info, debug, trace).") flag.StringVar(&httpAddressFlag, "http-address", "0.0.0.0:8090", "The address for the HTTP server, including port, to listen. Only used if --enable_grpc_gateway is set to true.") @@ -80,6 +83,10 @@ func init() { func main() { ctx := context.Background() + if secretsDir != "" { + secrets.SetSecretsDir(secretsDir) + } + hasErrors := false defer func() { if hasErrors { diff --git a/generator/renderer/BUILD.bazel b/generator/renderer/BUILD.bazel index 73f7a51..1ae50f1 100644 --- a/generator/renderer/BUILD.bazel +++ b/generator/renderer/BUILD.bazel @@ -8,6 +8,7 @@ go_library( deps = [ "@com_github_imdario_mergo//:mergo", "@com_github_masterminds_sprig_v3//:sprig", + "@io_k8s_sigs_yaml//:yaml", ], ) diff --git a/generator/renderer/main.go b/generator/renderer/main.go index 5825728..9172ec9 100644 --- a/generator/renderer/main.go +++ b/generator/renderer/main.go @@ -30,6 +30,7 @@ import ( sprig "github.com/Masterminds/sprig/v3" "github.com/imdario/mergo" + "sigs.k8s.io/yaml" ) type stringListFlags []string @@ -120,6 +121,12 @@ func (*Renderer) Render( Option("missingkey=error"). // If a key is missing when rendering, return an error. Delims(customDelims.open, customDelims.close). Funcs(sprig.HermeticTxtFuncMap()). + Funcs(template.FuncMap{ + "toYaml": func(o interface{}) string { + b, _ := yaml.Marshal(o) + return strings.TrimSpace(string(b)) + }, + }). ParseFiles(templateFilename) if err != nil { return fmt.Errorf("failed to render template: %w", err) diff --git a/go.mod b/go.mod index 7c0786d..895e3a0 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( google.golang.org/grpc v1.51.0 helm.sh/helm/v3 v3.10.2 sigs.k8s.io/kind v0.17.0 + sigs.k8s.io/yaml v1.3.0 ) require ( @@ -156,5 +157,4 @@ require ( sigs.k8s.io/kustomize/api v0.12.1 // indirect sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect - sigs.k8s.io/yaml v1.3.0 // indirect ) diff --git a/service/secrets/BUILD.bazel b/service/secrets/BUILD.bazel new file mode 100644 index 0000000..2abcce1 --- /dev/null +++ b/service/secrets/BUILD.bazel @@ -0,0 +1,8 @@ +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "secrets", + srcs = ["secrets.go"], + importpath = "github.com/aspect-build/talkie/service/secrets", + visibility = ["//visibility:public"], +) diff --git a/service/secrets/secrets.go b/service/secrets/secrets.go new file mode 100644 index 0000000..efd8106 --- /dev/null +++ b/service/secrets/secrets.go @@ -0,0 +1,63 @@ +// Copyright 2022 Aspect Build Systems, Inc. All rights reserved. +// +// Original authors: Thulio Ferraz Assis (thulio@aspect.dev) +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package secrets + +import ( + "fmt" + "io" + "io/fs" + "os" +) + +// Secrets is a singleton that manages secrets in a filesystem. +var secrets internalSecrets + +// SetSecretsDir sets the internal secrets filesystem to the tree of files +// rooted at the given dir. +func SetSecretsDir(dir string) { + secrets.setSecretsDir(dir) +} + +// Get returns the value of the given secret. It must match one of the requested +// secrets in the talkie_service rule. +func Get(secret string) (string, error) { + return secrets.get(secret) +} + +type internalSecrets struct { + secretsDir fs.FS +} + +func (s *internalSecrets) setSecretsDir(dir string) { + s.secretsDir = os.DirFS(dir) +} + +func (s *internalSecrets) get(secret string) (string, error) { + if s.secretsDir == nil { + return "", fmt.Errorf("failed to get secret %q: secrets filesystem not initialized", secret) + } + secretFile, err := s.secretsDir.Open(secret) + if err != nil { + return "", fmt.Errorf("failed to get secret %q: %w", secret, err) + } + defer secretFile.Close() + redisURL, err := io.ReadAll(secretFile) + if err != nil { + return "", fmt.Errorf("failed to get secret %q: %w", secret, err) + } + return string(redisURL), nil +}