Skip to content

Commit

Permalink
Initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
zonque committed May 4, 2023
0 parents commit 57b3c9e
Show file tree
Hide file tree
Showing 17 changed files with 1,411 additions and 0 deletions.
9 changes: 9 additions & 0 deletions LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# MIT LICENSE

Copyright (C) 2023 Holoplot GmbH

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
114 changes: 114 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
# kubelish - mDNS service publisher for Kubernetes cluster nodes

This project implements a way to expose k8s services with external IPs as mDNS services.

The rationale behind this project is to allow k8s services to be discovered by
devices on the local network dynamically, without the need to configure static IPs
or DNS entries. This is particularly useful for IoT devices, which are often unable
to become part of a traditional service mesh but need to access services of
the k8s cluster.

In contrast to other solutions, this project exposes mDNS services (SRV records)
rather than hosts (A records) which has the advantage that the daemon can run
on multiple nodes of the same cluster that have IP addresses in the same subnet,
and offer the service on each of them. Clients will hence pick one of the nodes at
random.

## How it works

The `kubelish` daemon runs on each node of the cluster and listens for new `LoadBalancer`
services with external IPs. When a service is created or updated, the daemon checks
the annotations of the service to see if it should be exposed.

The following keys are required:

```yaml
metadata:
annotations:
kubelish/service-name: Example
kubelish/service-type: _example._tcp
kubelish/txt: Optional TXT record to be exposed along with the service on mDNS
```
If the service is annotated that way, and one of its external IP addresses is
a local IP address of the node the daemon is running on, the service will be
exposed as an mDNS service.
If a service is deleted or updated to not be exposed, the daemon will remove
the mDNS service.
## Running the daemon
The daemon must be run natively on a node of the cluster, outside of Kubernetes.
Install the daemon by using
```bash
go install github.com/holoplot/kubelish/cmd/kubelish@latest
```

Then move the binary to a suitable location in your `$PATH`, e.g. `/usr/local/bin/kubelish`.

The binary supports the following commands:

- `kubelish watch` - Runs the watcher daemon
- `kubelish add <k8s-service>` - Adds annotations to a service to expose it as an mDNS service and dumps the YAML to stdout
- `kubelish remove <k8s-service>` - Removes annotations from a service and dumps the YAML to stdout

A systemd service file is [kubelish.service](provided). Make sure you
edit the file to set the correct path to the binary and the kubeconfig file.

### Global flags

The following flags can be used with all commands:
* `--namespace` - Namespace to watch for services. Defaults to `default`.

### Environment variables

The environment variables below can be used to configure the daemon:

- `KUBECONFIG` - Path to the kubeconfig file to use. Defaults to `~/.kube/config`.

### Example

Let's assume we have a service called `my-loadbalancer` in the `default` namespace that
you want to expose as an mDNS service.

First, run the daemon on the node that has the IP address of the service:

```bash
kubelish watch
```

Then, in a second terminal, add the annotations to the service using the following command:

```bash
kubelish add my-loadbalancer --service-name example --service-type _example._tcp --txt Example \
| kubectl apply -f -
```

The service should now be exposed as an mDNS service. You can verify this by using
the `avahi-browse` command:

```bash
avahi-browse -r _example._tcp
```

To remove the service, run the following command:

```bash
kubelish remove my-loadbalancer | kubectl apply -f -
```

## Limitations

This project is still in its early stages and has some limitations:

- Only supports Linux
- Depends on `avahi-daemon` to be installed on the host
- Only services with a single exposed port are supported
- Does not support miniKube's way of exposing services with `minikube tunnel`

## License

This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
76 changes: 76 additions & 0 deletions cmd/kubelish/cmd/add.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package cmd

import (
"github.com/holoplot/kubelish/pkg/meta"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)

var (
serviceName string
serviceType string
txt string
)

func doAdd(cmd *cobra.Command, args []string) {
kubeConfig := getKubeConfig()

if len(args) != 1 {
log.Fatal().Msg("You must specify a k8s service name")
}

if serviceName == "" {
log.Fatal().Msg("You must specify an mDNS service name")
}

if serviceType == "" {
log.Fatal().Msg("You must specify an mDNS service type")
}

config, err := clientcmd.BuildConfigFromFlags("", kubeConfig)

if err != nil {
log.Fatal().Err(err).Msg("Failed to build config from flags")
}

clientSet, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create clientset")
}

svc, err := clientSet.CoreV1().Services(namespace).Get(cmd.Context(), args[0], metav1.GetOptions{})
if err != nil {
log.Fatal().Err(err).Msg("Failed to get service")
}

an := meta.Annotations{
ServiceName: serviceName,
ServiceType: serviceType,
Txt: txt,
}

meta.AddAnnotationsToService(svc, &an)

dumpServiceYAML(svc)
}

func addCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "add <k8s-service>",
Short: "Add annotations to a Kubernetes service",
Long: "Add annotations to a Kubernetes service and print the resulting yaml to stdout",
Run: doAdd,
PreRun: func(cmd *cobra.Command, args []string) {
setupLogger()
},
}

cmd.Flags().StringVarP(&serviceName, "service-name", "s", "", "The mDNS service name")
cmd.Flags().StringVarP(&serviceType, "service-type", "t", "", "The mDNS service type")
cmd.Flags().StringVarP(&txt, "txt", "x", "", "The mDNS TXT record")

return cmd
}
51 changes: 51 additions & 0 deletions cmd/kubelish/cmd/remove.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package cmd

import (
"github.com/holoplot/kubelish/pkg/meta"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
)

func doRemove(cmd *cobra.Command, args []string) {
kubeConfig := getKubeConfig()

if len(args) != 1 {
log.Fatal().Msg("You must specify a k8s service name")
}

config, err := clientcmd.BuildConfigFromFlags("", kubeConfig)

if err != nil {
log.Fatal().Err(err).Msg("Failed to build config from flags")
}

clientSet, err := kubernetes.NewForConfig(config)
if err != nil {
log.Fatal().Err(err).Msg("Failed to create clientset")
}

svc, err := clientSet.CoreV1().Services(namespace).Get(cmd.Context(), args[0], metav1.GetOptions{})
if err != nil {
log.Fatal().Err(err).Msg("Failed to get service")
}

meta.RemoveAnnotationsFromService(svc)

dumpServiceYAML(svc)
}

func removeCmd() *cobra.Command {
return &cobra.Command{
Use: "remove <k8s-service>",
Long: `Remove annotations from a Kubernetes service and print the resulting yaml to stdout`,
Run: func(cmd *cobra.Command, args []string) {
doRemove(cmd, args)
},
PreRun: func(cmd *cobra.Command, args []string) {
setupLogger()
},
}
}
33 changes: 33 additions & 0 deletions cmd/kubelish/cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package cmd

import (
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)

var (
debug *bool
namespace string
)

func Execute() {
rootCmd := &cobra.Command{
Use: "kubelish",
Short: "kubelish is a service discovery tool for Kubernetes",
Long: `kubelish is a service discovery tool for Kubernetes. It publishes Kubernetes services to the local network using mDNS.`,
PersistentPreRun: func(cmd *cobra.Command, args []string) {
setupLogger()
},
}

debug = rootCmd.PersistentFlags().BoolP("debug", "d", false, "Enable debug log output")
rootCmd.PersistentFlags().StringVarP(&namespace, "namespace", "n", "default", "The namespace to operate on")

rootCmd.AddCommand(addCmd())
rootCmd.AddCommand(removeCmd())
rootCmd.AddCommand(watchCmd())

if err := rootCmd.Execute(); err != nil {
log.Fatal().Err(err).Msg("Failed to execute root command")
}
}
40 changes: 40 additions & 0 deletions cmd/kubelish/cmd/utils.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package cmd

import (
"os"
"path/filepath"

"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/util/homedir"
"sigs.k8s.io/yaml"
)

func setupLogger() {
if *debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
}

func getKubeConfig() string {
kubeConfig, ok := os.LookupEnv("KUBECONFIG")
if !ok {
kubeConfig = filepath.Join(homedir.HomeDir(), ".kube", "config")
}
return kubeConfig
}

func dumpServiceYAML(svc *corev1.Service) {
svc.Kind = "Service"
svc.APIVersion = "v1"
svc.ManagedFields = make([]metav1.ManagedFieldsEntry, 0)

b, err := yaml.Marshal(svc)
if err != nil {
log.Fatal().Err(err).Msg("Failed to marshal service")
}

os.Stdout.Write(b)
}
Loading

0 comments on commit 57b3c9e

Please sign in to comment.