cluster/admitomatic: finish up service

This turns admitomatic into a self-standing service that can be used as
an admission controller.

I've tested this E2E on a local k3s server, and have some early test
code for that - but that'll land up in a follow up CR, as it first needs
to be cleaned up.

Change-Id: I46da0fc49f9d1a3a1a96700a36deb82e5057249b
changes/96/696/2
q3k 2021-01-31 01:17:38 +01:00
parent 5d2c8fcda0
commit c6118649ab
7 changed files with 206 additions and 4 deletions

View File

@ -5,15 +5,18 @@ go_library(
srcs = [
"ingress.go",
"main.go",
"service.go",
],
importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic",
visibility = ["//visibility:private"],
deps = [
"//cluster/admitomatic/config:go_default_library",
"//go/mirko:go_default_library",
"@com_github_golang_glog//:go_default_library",
"@io_k8s_api//admission/v1beta1:go_default_library",
"@io_k8s_api//networking/v1beta1:go_default_library",
"@io_k8s_apimachinery//pkg/apis/meta/v1:go_default_library",
"@org_golang_google_protobuf//encoding/prototext:go_default_library",
],
)

View File

@ -0,0 +1,23 @@
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@io_bazel_rules_go//go:def.bzl", "go_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
proto_library(
name = "config_proto",
srcs = ["config.proto"],
visibility = ["//visibility:public"],
)
go_proto_library(
name = "config_go_proto",
importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic/config",
proto = ":config_proto",
visibility = ["//visibility:public"],
)
go_library(
name = "go_default_library",
embed = [":config_go_proto"],
importpath = "code.hackerspace.pl/hscloud/cluster/admitomatic/config",
visibility = ["//visibility:public"],
)

View File

@ -0,0 +1,37 @@
syntax = "proto3";
package config;
option go_package = "code.hackerspace.pl/hscloud/cluster/admitomatic/config";
// Admitomatic configuration, passed as a text proto, for
// example:
//
// $ cat sample.pb.text
// allow_domain { namespace: "example" dns: "*.example.com" }
// allow_domain {
// namespace: "personal-q3k" dns: "foo.q3k.org"
// }
// allow_domain {
// namespace: "personal-q3k" dns: "bar.q3k.org"
// }
//
message Config {
// List of domains that are allowed to be configured as
// ingresses in a given namespace. If a domain does not
// appear in this list, it will be allowed to run in any
// namespace.
repeated AllowDomain allow_domain = 1;
}
message AllowDomain {
// namespace is a kubernetes namespace. An empty string is
// treated as the 'default' namespace.
string namespace = 1;
// dns is a domain name like 'example.com' or a wildcard
// like '*.foo.example.com'.
// Wildcards match domains at any level beneath the root,
// so the example above would match 'bar.foo.example.com'
// and 'baz.bar.foo.example.com'. However, they do not
// catch the root itself, ie. the above would not catch
// 'foo.example.com'.
string dns = 2;
}

View File

@ -0,0 +1,7 @@
allow_domain { namespace: "example" dns: "*.example.com" }
allow_domain {
namespace: "personal-q3k" dns: "foo.q3k.org"
}
allow_domain {
namespace: "personal-q3k" dns: "bar.q3k.org"
}

View File

@ -6,7 +6,6 @@ import (
"strings"
"github.com/golang/glog"
admission "k8s.io/api/admission/v1beta1"
networking "k8s.io/api/networking/v1beta1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"

View File

@ -3,6 +3,7 @@ package main
import (
"context"
"flag"
"io/ioutil"
"net/http"
"time"
@ -11,13 +12,29 @@ import (
)
var (
flagListen = "127.0.0.1:8080"
flagListen = "127.0.0.1:8080"
flagConfig = ""
flagTLSKey = ""
flagTLSCert = ""
)
func main() {
flag.StringVar(&flagListen, "pub_listen", flagListen, "Address to listen on for HTTP traffic")
flag.StringVar(&flagListen, "admitomatic_listen", flagListen, "Address to listen on for HTTP traffic")
flag.StringVar(&flagTLSKey, "admitomatic_tls_key", flagTLSKey, "TLS key to serve HTTP with")
flag.StringVar(&flagTLSCert, "admitomatic_tls_cert", flagTLSCert, "TLS certificate to serve HTTP with")
flag.StringVar(&flagConfig, "admitomatic_config", flagConfig, "Config path (prototext format)")
flag.Parse()
if flagConfig == "" {
glog.Exitf("-admitomatic_config must be set")
}
if flagTLSKey == "" {
glog.Exitf("-admitomatic_tls_key must be set")
}
if flagTLSCert == "" {
glog.Exitf("-admitomatic_tls_cert must be set")
}
m := mirko.New()
if err := m.Listen(); err != nil {
glog.Exitf("Listen(): %v", err)
@ -27,13 +44,24 @@ func main() {
glog.Exitf("Serve(): %v", err)
}
configData, err := ioutil.ReadFile(flagConfig)
if err != nil {
glog.Exitf("Could not read config: %v", err)
}
s, err := newService(configData)
if err != nil {
glog.Exitf("Could not start service: %v", err)
}
mux := http.NewServeMux()
mux.HandleFunc("/", s.handler)
// TODO(q3k): implement admission controller
srv := &http.Server{Addr: flagListen, Handler: mux}
glog.Infof("Listening on %q...", flagListen)
go func() {
if err := srv.ListenAndServe(); err != nil {
if err := srv.ListenAndServeTLS(flagTLSCert, flagTLSKey); err != nil {
glog.Error(err)
}
}()

View File

@ -0,0 +1,105 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/golang/glog"
"google.golang.org/protobuf/encoding/prototext"
admission "k8s.io/api/admission/v1beta1"
meta "k8s.io/apimachinery/pkg/apis/meta/v1"
pb "code.hackerspace.pl/hscloud/cluster/admitomatic/config"
)
type service struct {
ingress ingressFilter
}
// newService creates an admitomatic service from a given prototext config.
func newService(configuration []byte) (*service, error) {
var cfg pb.Config
if err := prototext.Unmarshal(configuration, &cfg); err != nil {
return nil, fmt.Errorf("parsing config: %v", err)
}
s := service{}
for i, ad := range cfg.AllowDomain {
if ad.Namespace == "" {
ad.Namespace = "default"
}
if ad.Dns == "" {
return nil, fmt.Errorf("config entry %d: dns must be set", i)
}
if err := s.ingress.allow(ad.Namespace, ad.Dns); err != nil {
return nil, fmt.Errorf("config entry %d: %v", i, err)
}
glog.Infof("Ingress: allowing %s in %s", ad.Dns, ad.Namespace)
}
return &s, nil
}
// handler is the main HTTP handler of the admitomatic service. It servers the
// AdmissionReview API, and is called by the Kubernetes API server to
// permit/deny creation/updating of resources.
func (s *service) handler(w http.ResponseWriter, r *http.Request) {
var body []byte
if r.Body != nil {
if data, err := ioutil.ReadAll(r.Body); err == nil {
body = data
}
}
if r.Method != "POST" {
glog.Errorf("%s %s: invalid method", r.Method, r.URL)
return
}
contentType := r.Header.Get("Content-Type")
if contentType != "application/json" {
glog.Errorf("%s %s: invalid content-type", r.Method, r.URL)
return
}
var review admission.AdmissionReview
if err := json.Unmarshal(body, &review); err != nil {
glog.Errorf("%s %s: cannot decode: %v", r.Method, r.URL, err)
return
}
if review.Kind != "AdmissionReview" {
glog.Errorf("%s %s: invalid Kind (%q)", r.Method, r.URL, review.Kind)
return
}
var err error
req := review.Request
resp := &admission.AdmissionResponse{
UID: req.UID,
Allowed: true,
}
switch {
case req.Kind.Group == "networking.k8s.io" && req.Kind.Kind == "Ingress":
resp, err = s.ingress.admit(req)
if err != nil {
glog.Errorf("%s %s %s: %v", req.Operation, req.Name, req.Namespace, err)
// Fail safe.
// TODO(q3k): monitor this?
resp = &admission.AdmissionResponse{
UID: req.UID,
Allowed: false,
Result: &meta.Status{
Code: 500,
Message: "admitomatic: internal server error",
},
}
}
}
glog.Infof("%s %s %s in %s: %v (%v)", req.Operation, req.Kind.Kind, req.Name, req.Namespace, resp.Allowed, resp.Result)
review.Response = resp
json.NewEncoder(w).Encode(review)
}