diff --git a/README.md b/README.md index 9e10506a..4995de05 100644 --- a/README.md +++ b/README.md @@ -18,25 +18,35 @@ The flag `-exitWhenReady` is also supported. ## Configuration The configuration file is an [HCL](https://github.com/hashicorp/hcl) formatted file that defines the following configurations: - | Configuration | Description | Example Value | - |-------------------------------|----------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| - | `agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | - | `cmd` | The path to the process to launch. | `"ghostunnel"` | - | `cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | - | `cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | - | `daemon_mode` | Toggle running as a daemon, keeping X.509 and JWT up to date; or just fetch X.509 and JWT and exit 0 | `true` | - | `add_intermediates_to_bundle` | Add intermediate certificates into Bundle file instead of SVID file. | `true` | - | `renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | - | `svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | - | `svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | - | `svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | - | `jwt_svids` | An array with the audience, optional extra audiences array, and file name to store the JWT SVIDs. File is Base64-encoded string). | `[{jwt_audience="your-audience", jwt_extra_audiences=["your-extra-audience-1", "your-extra-audience-2"], jwt_svid_file_name="jwt_svid.token"}]` | - | `jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | - | `include_federated_domains` | Include trust domains from federated servers in the CA bundle. | `true` | - | `cert_file_mode` | The octal file mode to use when saving the X.509 public certificate file. | `0644` | - | `key_file_mode` | The octal file mode to use when saving the X.509 private key file. | `0600` | - | `jwt_bundle_file_mode` | The octal file mode to use when saving a JWT Bundle file. | `0600` | - | `jwt_svid_file_mode` | The octal file mode to use when saving a JWT SVID file. | `0600` | + | Configuration | Description | Example Value | + |-------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| + | `agent_address` | Socket address of SPIRE Agent. | `"/tmp/agent.sock"` | + | `cmd` | The path to the process to launch. | `"ghostunnel"` | + | `cmd_args` | The arguments of the process to launch. | `"server --listen localhost:8002 --target localhost:8001--keystore certs/svid_key.pem --cacert certs/svid_bundle.pem --allow-uri-san spiffe://example.org/Database"` | + | `cert_dir` | Directory name to store the fetched certificates. This directory must be created previously. | `"certs"` | + | `daemon_mode` | Toggle running as a daemon, keeping X.509 and JWT up to date; or just fetch X.509 and JWT and exit 0 | `true` | + | `add_intermediates_to_bundle` | Add intermediate certificates into Bundle file instead of SVID file. | `true` | + | `renew_signal` | The signal that the process to be launched expects to reload the certificates. It is not supported on Windows. | `"SIGUSR1"` | + | `svid_file_name` | File name to be used to store the X.509 SVID public certificate in PEM format. | `"svid.pem"` | + | `svid_key_file_name` | File name to be used to store the X.509 SVID private key and public certificate in PEM format. | `"svid_key.pem"` | + | `svid_bundle_file_name` | File name to be used to store the X.509 SVID Bundle in PEM format. | `"svid_bundle.pem"` | + | `jwt_svids` | An array with the audience, optional extra audiences array, and file name to store the JWT SVIDs. File is Base64-encoded string). | `[{jwt_audience="your-audience", jwt_extra_audiences=["your-extra-audience-1", "your-extra-audience-2"], jwt_svid_file_name="jwt_svid.token"}]` | + | `jwt_bundle_file_name` | File name to be used to store JWT Bundle in JSON format. | `"jwt_bundle.json"` | + | `include_federated_domains` | Include trust domains from federated servers in the CA bundle. | `true` | + | `cert_file_mode` | The octal file mode to use when saving the X.509 public certificate file. | `0644` | + | `key_file_mode` | The octal file mode to use when saving the X.509 private key file. | `0600` | + | `jwt_bundle_file_mode` | The octal file mode to use when saving a JWT Bundle file. | `0600` | + | `jwt_svid_file_mode` | The octal file mode to use when saving a JWT SVID file. | `0600` | + +### Health Checks Configuration +SPIFFE Helper can expose and endpoint that can be used for health checking + + | Configuration | Description | Example Value | + |----------------------------------|----------------------------------------------------------------------------------------------------------------------|---------------| + | `health_checks.listener_enabled` | Whether to start an HTTP server at the configured endpoint for the daemon health. Doesn't apply for non-daemon mode. | `false` | + | `health_checks.bind_port` | The port to run the HTTP health server. | `8081` | + | `health_checks.liveness_path` | The URL path for the liveness health check | `/live` | + | `health_checks.readiness_path` | The URL path for the readiness health check | `/readu` | ### Configuration example ``` diff --git a/cmd/spiffe-helper/config/config.go b/cmd/spiffe-helper/config/config.go index fe32877c..81ca4980 100644 --- a/cmd/spiffe-helper/config/config.go +++ b/cmd/spiffe-helper/config/config.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/hcl" "github.com/hashicorp/hcl/hcl/token" "github.com/sirupsen/logrus" + "github.com/spiffe/spiffe-helper/pkg/health" "github.com/spiffe/spiffe-helper/pkg/sidecar" ) @@ -21,22 +22,26 @@ const ( defaultKeyFileMode = 0600 defaultJWTBundleFileMode = 0600 defaultJWTSVIDFileMode = 0600 + defaultBindPort = 8081 + defaultLivenessPath = "/live" + defaultReadinessPath = "/ready" ) type Config struct { - AddIntermediatesToBundle bool `hcl:"add_intermediates_to_bundle"` - AgentAddress string `hcl:"agent_address"` - Cmd string `hcl:"cmd"` - CmdArgs string `hcl:"cmd_args"` - PIDFileName string `hcl:"pid_file_name"` - CertDir string `hcl:"cert_dir"` - CertFileMode int `hcl:"cert_file_mode"` - KeyFileMode int `hcl:"key_file_mode"` - JWTBundleFileMode int `hcl:"jwt_bundle_file_mode"` - JWTSVIDFileMode int `hcl:"jwt_svid_file_mode"` - IncludeFederatedDomains bool `hcl:"include_federated_domains"` - RenewSignal string `hcl:"renew_signal"` - DaemonMode *bool `hcl:"daemon_mode"` + AddIntermediatesToBundle bool `hcl:"add_intermediates_to_bundle"` + AgentAddress string `hcl:"agent_address"` + Cmd string `hcl:"cmd"` + CmdArgs string `hcl:"cmd_args"` + PIDFileName string `hcl:"pid_file_name"` + CertDir string `hcl:"cert_dir"` + CertFileMode int `hcl:"cert_file_mode"` + KeyFileMode int `hcl:"key_file_mode"` + JWTBundleFileMode int `hcl:"jwt_bundle_file_mode"` + JWTSVIDFileMode int `hcl:"jwt_svid_file_mode"` + IncludeFederatedDomains bool `hcl:"include_federated_domains"` + RenewSignal string `hcl:"renew_signal"` + DaemonMode *bool `hcl:"daemon_mode"` + HealthCheck health.CheckConfig `hcl:"health_checks"` Hint string `hcl:"hint"` // x509 configuration @@ -59,8 +64,8 @@ type JWTConfig struct { UnusedKeyPositions map[string][]token.Pos `hcl:",unusedKeyPositions"` } -// ParseConfig parses the given HCL file into a Config struct -func ParseConfig(file string) (*Config, error) { +// ParseConfigFile parses the given HCL file into a Config struct +func ParseConfigFile(file string) (*Config, error) { // Read HCL file dat, err := os.ReadFile(file) if err != nil { @@ -159,6 +164,21 @@ func (c *Config) ValidateConfig(log logrus.FieldLogger) error { c.JWTSVIDFileMode = defaultJWTSVIDFileMode } + if c.HealthCheck.ListenerEnabled { + if c.HealthCheck.BindPort < 0 { + return errors.New("bind port must be positive") + } + if c.HealthCheck.BindPort == 0 { + c.HealthCheck.BindPort = defaultBindPort + } + if c.HealthCheck.LivenessPath == "" { + c.HealthCheck.LivenessPath = defaultLivenessPath + } + if c.HealthCheck.ReadinessPath == "" { + c.HealthCheck.ReadinessPath = defaultReadinessPath + } + } + return nil } @@ -177,6 +197,15 @@ func (c *Config) checkForUnknownConfig() error { return nil } +func ParseConfig(configFile string, daemonModeFlag bool, daemonModeFlagName string) (*Config, error) { + hclConfig, err := ParseConfigFile(configFile) + if err != nil { + return nil, fmt.Errorf("failed to parse %q: %w", configFile, err) + } + hclConfig.ParseConfigFlagOverrides(daemonModeFlag, daemonModeFlagName) + return hclConfig, nil +} + func NewSidecarConfig(config *Config, log logrus.FieldLogger) *sidecar.Config { sidecarConfig := &sidecar.Config{ AddIntermediatesToBundle: config.AddIntermediatesToBundle, diff --git a/cmd/spiffe-helper/config/config_test.go b/cmd/spiffe-helper/config/config_test.go index 94e71adb..337d5ec6 100644 --- a/cmd/spiffe-helper/config/config_test.go +++ b/cmd/spiffe-helper/config/config_test.go @@ -15,7 +15,7 @@ const ( ) func TestParseConfig(t *testing.T) { - c, err := ParseConfig("testdata/helper.conf") + c, err := ParseConfigFile("testdata/helper.conf") assert.NoError(t, err) @@ -185,7 +185,7 @@ func TestDetectsUnknownConfig(t *testing.T) { _, err = configFile.WriteString(tt.config) require.NoError(t, err) - c, err := ParseConfig(configFile.Name()) + c, err := ParseConfigFile(configFile.Name()) require.NoError(t, err) log, _ := test.NewNullLogger() diff --git a/cmd/spiffe-helper/main.go b/cmd/spiffe-helper/main.go index 295b67b8..d33549d5 100644 --- a/cmd/spiffe-helper/main.go +++ b/cmd/spiffe-helper/main.go @@ -3,13 +3,13 @@ package main import ( "context" "flag" - "fmt" "os" "os/signal" "syscall" "github.com/sirupsen/logrus" "github.com/spiffe/spiffe-helper/cmd/spiffe-helper/config" + "github.com/spiffe/spiffe-helper/pkg/health" "github.com/spiffe/spiffe-helper/pkg/sidecar" ) @@ -23,7 +23,19 @@ func main() { flag.Parse() log := logrus.WithField("system", "spiffe-helper") - if err := startSidecar(*configFile, *daemonModeFlag, log); err != nil { + log.Infof("Using configuration file: %q", *configFile) + hclConfig, err := config.ParseConfig(*configFile, *daemonModeFlag, daemonModeFlagName) + if err != nil { + log.WithError(err).Errorf("failed to parse configuration") + os.Exit(1) + } + + if err := hclConfig.ValidateConfig(log); err != nil { + log.WithError(err).Errorf("invalid configuration") + os.Exit(1) + } + + if err = startSidecar(hclConfig, log); err != nil { log.WithError(err).Errorf("Error starting spiffe-helper") os.Exit(1) } @@ -32,24 +44,19 @@ func main() { os.Exit(0) } -func startSidecar(configFile string, daemonModeFlag bool, log logrus.FieldLogger) error { +func startSidecar(hclConfig *config.Config, log logrus.FieldLogger) error { + sidecarConfig := config.NewSidecarConfig(hclConfig, log) + spiffeSidecar := sidecar.New(sidecarConfig) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() - log.Infof("Using configuration file: %q", configFile) - hclConfig, err := config.ParseConfig(configFile) - if err != nil { - return fmt.Errorf("failed to parse %q: %w", configFile, err) - } - hclConfig.ParseConfigFlagOverrides(daemonModeFlag, daemonModeFlagName) - - if err := hclConfig.ValidateConfig(log); err != nil { - return fmt.Errorf("invalid configuration: %w", err) + if *hclConfig.DaemonMode && hclConfig.HealthCheck.ListenerEnabled { + log.Info("Starting health server") + if err := health.StartHealthServer(hclConfig.HealthCheck, log, spiffeSidecar); err != nil { + return err + } } - sidecarConfig := config.NewSidecarConfig(hclConfig, log) - spiffeSidecar := sidecar.New(sidecarConfig) - if !*hclConfig.DaemonMode { log.Info("Daemon mode disabled") return spiffeSidecar.Run(ctx) diff --git a/go.mod b/go.mod index 586c26c2..c29ca2e6 100644 --- a/go.mod +++ b/go.mod @@ -8,8 +8,8 @@ require ( github.com/stretchr/testify v1.10.0 golang.org/x/sys v0.29.0 google.golang.org/grpc v1.69.2 - k8s.io/apimachinery v0.32.0 - k8s.io/client-go v0.32.0 + k8s.io/apimachinery v0.32.1 + k8s.io/client-go v0.32.1 ) require ( diff --git a/go.sum b/go.sum index 244c0e00..b549ef40 100644 --- a/go.sum +++ b/go.sum @@ -149,10 +149,10 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -k8s.io/apimachinery v0.32.0 h1:cFSE7N3rmEEtv4ei5X6DaJPHHX0C+upp+v5lVPiEwpg= -k8s.io/apimachinery v0.32.0/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= -k8s.io/client-go v0.32.0 h1:DimtMcnN/JIKZcrSrstiwvvZvLjG0aSxy8PxN8IChp8= -k8s.io/client-go v0.32.0/go.mod h1:boDWvdM1Drk4NJj/VddSLnx59X3OPgwrOo0vGbtq9+8= +k8s.io/apimachinery v0.32.1 h1:683ENpaCBjma4CYqsmZyhEzrGz6cjn1MY/X2jB2hkZs= +k8s.io/apimachinery v0.32.1/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE= +k8s.io/client-go v0.32.1 h1:otM0AxdhdBIaQh7l1Q0jQpmo7WOFIk5FFa4bg6YMdUU= +k8s.io/client-go v0.32.1/go.mod h1:aTTKZY7MdxUaJ/KiUs8D+GssR9zJZi77ZqtzcGXIiDg= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro= diff --git a/pkg/health/health.go b/pkg/health/health.go new file mode 100644 index 00000000..5dc5f434 --- /dev/null +++ b/pkg/health/health.go @@ -0,0 +1,80 @@ +package health + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/sirupsen/logrus" + "github.com/spiffe/spiffe-helper/pkg/sidecar" +) + +type CheckConfig struct { + ListenerEnabled bool `hcl:"listener_enabled"` + BindPort int `hcl:"bind_port"` + LivenessPath string `hcl:"liveness_path"` + ReadinessPath string `hcl:"readiness_path"` +} + +const ( + contentTypeJSON = "application/json" + contentTypePlainText = "text/plain" + statusOK = http.StatusOK + statusServiceUnavailable = http.StatusServiceUnavailable +) + +func StartHealthServer(healthCheckConfig CheckConfig, log logrus.FieldLogger, sidecar *sidecar.Sidecar) error { + http.HandleFunc(healthCheckConfig.LivenessPath, func(w http.ResponseWriter, _ *http.Request) { + writeResponse(w, sidecar.CheckLiveness(), log, sidecar) + }) + http.HandleFunc(healthCheckConfig.ReadinessPath, func(w http.ResponseWriter, _ *http.Request) { + writeResponse(w, sidecar.CheckReadiness(), log, sidecar) + }) + server := &http.Server{ + Addr: ":" + strconv.Itoa(healthCheckConfig.BindPort), + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 5 * time.Second, + } + log.Fatal(server.ListenAndServe()) + return nil +} + +type Response struct { + Status string `json:"status"` + Health sidecar.Health `json:"health"` +} + +func writeResponse(w http.ResponseWriter, goodStatus bool, log logrus.FieldLogger, sidecar *sidecar.Sidecar) { + statusCode := statusOK + statusText := http.StatusText(statusOK) + + if !goodStatus { + statusCode = statusServiceUnavailable + statusText = http.StatusText(statusServiceUnavailable) + } + + response := Response{ + Status: statusText, + Health: sidecar.GetHealth(), + } + + jsonBytes, err := json.Marshal(response) + if err != nil { + log.WithError(err).Errorf("failed marshalling response") + w.Header().Set("Content-Type", contentTypePlainText) + w.WriteHeader(statusCode) + _, err = w.Write([]byte(statusText)) + if err != nil { + log.WithError(err).Errorf("failed writing response text") + } + return + } + + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(statusCode) + _, err = w.Write(jsonBytes) + if err != nil { + log.WithError(err).Errorf("failed writing response JSON") + } +} diff --git a/pkg/sidecar/sidecar.go b/pkg/sidecar/sidecar.go index 9bb1279d..1d6cdae8 100644 --- a/pkg/sidecar/sidecar.go +++ b/pkg/sidecar/sidecar.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "os/exec" + "path" "strconv" "strings" "sync" @@ -30,14 +31,43 @@ type Sidecar struct { processRunning int32 process *os.Process certReadyChan chan struct{} + health Health } +type Health struct { + FileWriteStatuses FileWriteStatuses `json:"file_write_statuses"` +} + +type FileWriteStatuses struct { + X509WriteStatus string `json:"x509_write_status"` + JWTWriteStatus map[string]string `json:"jwt_write_status"` +} + +const ( + writeStatusUnwritten = "unwritten" + writeStatusFailed = "failed" + writeStatusWritten = "written" +) + // New creates a new SPIFFE sidecar func New(config *Config) *Sidecar { - return &Sidecar{ + sidecar := &Sidecar{ config: config, certReadyChan: make(chan struct{}, 1), - } + health: Health{ + FileWriteStatuses: FileWriteStatuses{ + X509WriteStatus: writeStatusUnwritten, + JWTWriteStatus: make(map[string]string), + }, + }, + } + for _, jwtConfig := range config.JWTSVIDs { + jwtSVIDFilename := path.Join(config.CertDir, jwtConfig.JWTSVIDFilename) + sidecar.health.FileWriteStatuses.JWTWriteStatus[jwtSVIDFilename] = writeStatusUnwritten + } + jwtBundleFilePath := path.Join(config.CertDir, config.JWTBundleFilename) + sidecar.health.FileWriteStatuses.JWTWriteStatus[jwtBundleFilePath] = writeStatusUnwritten + return sidecar } // RunDaemon starts the main loop @@ -169,8 +199,10 @@ func (s *Sidecar) updateCertificates(svidResponse *workloadapi.X509Context) { s.config.Log.Debug("Updating X.509 certificates") if err := disk.WriteX509Context(svidResponse, s.config.AddIntermediatesToBundle, s.config.IncludeFederatedDomains, s.config.CertDir, s.config.SVIDFileName, s.config.SVIDKeyFileName, s.config.SVIDBundleFileName, s.config.CertFileMode, s.config.KeyFileMode, s.config.Hint); err != nil { s.config.Log.WithError(err).Error("Unable to dump bundle") + s.health.FileWriteStatuses.X509WriteStatus = writeStatusFailed return } + s.health.FileWriteStatuses.X509WriteStatus = writeStatusWritten s.config.Log.Info("X.509 certificates updated") if s.config.Cmd != "" { @@ -302,10 +334,13 @@ func (s *Sidecar) performJWTSVIDUpdate(ctx context.Context, jwtAudience string, return nil, err } + jwtSVIDPath := path.Join(s.config.CertDir, jwtSVIDFilename) if err = disk.WriteJWTSVID(jwtSVIDs, s.config.CertDir, jwtSVIDFilename, s.config.JWTSVIDFileMode, s.config.Hint); err != nil { s.config.Log.Errorf("Unable to update JWT SVID: %v", err) + s.health.FileWriteStatuses.JWTWriteStatus[jwtSVIDPath] = writeStatusFailed return nil, err } + s.health.FileWriteStatuses.JWTWriteStatus[jwtSVIDPath] = writeStatusWritten s.config.Log.Info("JWT SVID updated") return jwtSVIDs, nil @@ -399,10 +434,13 @@ type JWTBundlesWatcher struct { // OnJWTBundlesUpdate is run every time a bundle is updated func (w JWTBundlesWatcher) OnJWTBundlesUpdate(jwkSet *jwtbundle.Set) { w.sidecar.config.Log.Debug("Updating JWT bundle") + jwtBundleFilePath := path.Join(w.sidecar.config.CertDir, w.sidecar.config.JWTBundleFilename) if err := disk.WriteJWTBundleSet(jwkSet, w.sidecar.config.CertDir, w.sidecar.config.JWTBundleFilename, w.sidecar.config.JWTBundleFileMode); err != nil { w.sidecar.config.Log.Errorf("Error writing JWT Bundle to disk: %v", err) + w.sidecar.health.FileWriteStatuses.JWTWriteStatus[jwtBundleFilePath] = writeStatusFailed return } + w.sidecar.health.FileWriteStatuses.JWTWriteStatus[jwtBundleFilePath] = writeStatusWritten w.sidecar.config.Log.Info("JWT bundle updated") } @@ -413,3 +451,25 @@ func (w JWTBundlesWatcher) OnJWTBundlesWatchError(err error) { w.sidecar.config.Log.Errorf("Error while watching JWT bundles: %v", err) } } + +func (s *Sidecar) CheckLiveness() bool { + for _, writeStatus := range s.health.FileWriteStatuses.JWTWriteStatus { + if writeStatus == writeStatusFailed { + return false + } + } + return s.health.FileWriteStatuses.X509WriteStatus != writeStatusFailed +} + +func (s *Sidecar) CheckReadiness() bool { + for _, writeStatus := range s.health.FileWriteStatuses.JWTWriteStatus { + if writeStatus != writeStatusWritten { + return false + } + } + return s.health.FileWriteStatuses.X509WriteStatus != writeStatusWritten +} + +func (s *Sidecar) GetHealth() Health { + return s.health +} diff --git a/pkg/sidecar/sidecar_test.go b/pkg/sidecar/sidecar_test.go index c462402b..473676ec 100644 --- a/pkg/sidecar/sidecar_test.go +++ b/pkg/sidecar/sidecar_test.go @@ -93,6 +93,12 @@ func TestSidecar_RunDaemon(t *testing.T) { sidecar := Sidecar{ config: config, certReadyChan: make(chan struct{}, 1), + health: Health{ + FileWriteStatuses: FileWriteStatuses{ + X509WriteStatus: writeStatusUnwritten, + JWTWriteStatus: make(map[string]string), + }, + }, } defer close(sidecar.certReadyChan)