Skip to content

Commit

Permalink
feat(talkie): user-provided secrets for services (#955)
Browse files Browse the repository at this point in the history
This enables us to request deployment-time secrets to be provided by the
user. E.g. a URL for a Redis instance. See the updated example.

Signed-off-by: Thulio Ferraz Assis <[email protected]>
  • Loading branch information
f0rmiga authored Dec 8, 2022
1 parent 2ebce39 commit 11082c0
Show file tree
Hide file tree
Showing 19 changed files with 233 additions and 26 deletions.
5 changes: 1 addition & 4 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1 @@
/bazel-bin
/bazel-out
/bazel-talkie
/bazel-testlogs
/bazel-*
64 changes: 59 additions & 5 deletions defs.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "",
Expand All @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.",
},
)

Expand All @@ -376,24 +384,62 @@ 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.
talkie_service_info = TalkieServiceInfo(
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 [
default_info,
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 = {
Expand All @@ -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,
Expand Down Expand Up @@ -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
],
Expand Down
1 change: 1 addition & 0 deletions examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/bazel-*
1 change: 1 addition & 0 deletions examples/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
17 changes: 17 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -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 `<redis_url>` 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=<redis_url>
```
3 changes: 3 additions & 0 deletions examples/helloworld/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions examples/helloworld/service/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ go_library(
deps = [
"//helloworld/protos",
"@aspect_talkie//service",
"@aspect_talkie//service/secrets",
],
)
7 changes: 7 additions & 0 deletions examples/helloworld/service/helloworld.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand All @@ -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
}

Expand Down
10 changes: 9 additions & 1 deletion examples/helloworld/tests/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 5 additions & 1 deletion examples/helloworld/tests/smoke_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"os"
"os/exec"
"path"
"strings"
"time"

Expand Down Expand Up @@ -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()
Expand All @@ -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"))
})
Expand Down
1 change: 1 addition & 0 deletions examples/helloworld/tests/testdata/secrets/redis.url
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
redis://127.0.0.1:6379
51 changes: 38 additions & 13 deletions generator/deployment/helm/chart/templates/talkie_services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,39 +16,41 @@
*/ -%>

<%- 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 }}
image: {{ printf "%s:%s" $service_config.image.repository $service_config.image.tag | quote }}
{{- 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"
Expand Down Expand Up @@ -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
Expand All @@ -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 }}
Expand All @@ -143,3 +167,4 @@ spec:
{{- end }}
{{- end }}
<%- end %>
<%- end %>
Loading

0 comments on commit 11082c0

Please sign in to comment.