Merge pull request 'Add companion TSIG API and BindTSIGAPI CRD' (#8) from benvin/tsig-companion-api into main
ci/woodpecker/tag/docker Pipeline was successful
ci/woodpecker/tag/docker Pipeline was successful
Reviewed-on: #8
This commit was merged in pull request #8.
This commit is contained in:
@@ -8,3 +8,10 @@ steps:
|
||||
repo: git.unkin.net/unkin/bind-operator
|
||||
dockerfile: Dockerfile.operator
|
||||
dry_run: true
|
||||
|
||||
- name: docker-build-tsig-api
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
repo: git.unkin.net/unkin/bind-tsig-api
|
||||
dockerfile: Dockerfile.tsigapi
|
||||
dry_run: true
|
||||
|
||||
@@ -15,3 +15,16 @@ steps:
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
|
||||
- name: docker-tsig-api
|
||||
image: woodpeckerci/plugin-docker-buildx
|
||||
settings:
|
||||
registry: git.unkin.net
|
||||
repo: git.unkin.net/unkin/bind-tsig-api
|
||||
dockerfile: Dockerfile.tsigapi
|
||||
username: droneci
|
||||
password:
|
||||
from_secret: DRONECI_PASSWORD
|
||||
tags:
|
||||
- ${CI_COMMIT_TAG}
|
||||
- latest
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
RUN apk add --no-cache git
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o tsig-api ./cmd/tsigapi
|
||||
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
COPY --from=builder /build/tsig-api /usr/local/bin/tsig-api
|
||||
|
||||
EXPOSE 8443
|
||||
|
||||
ENTRYPOINT ["tsig-api"]
|
||||
@@ -0,0 +1,104 @@
|
||||
package v1alpha1
|
||||
|
||||
import (
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
)
|
||||
|
||||
// BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||
// The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||
// creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||
// custom resources, which the operator then reconciles into key material.
|
||||
type BindTSIGAPISpec struct {
|
||||
// Image is the companion API container image.
|
||||
// +kubebuilder:default="git.unkin.net/unkin/bind-tsig-api:latest"
|
||||
// +optional
|
||||
Image string `json:"image,omitempty"`
|
||||
|
||||
// ImagePullPolicy for the API container.
|
||||
// +optional
|
||||
ImagePullPolicy corev1.PullPolicy `json:"imagePullPolicy,omitempty"`
|
||||
|
||||
// Replicas of the API. Defaults to 1.
|
||||
// +kubebuilder:default=1
|
||||
// +optional
|
||||
Replicas int32 `json:"replicas,omitempty"`
|
||||
|
||||
// Port the API listens on. Defaults to 8443.
|
||||
// +kubebuilder:default=8443
|
||||
// +optional
|
||||
Port int32 `json:"port,omitempty"`
|
||||
|
||||
// TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||
// to the API's own namespace.
|
||||
// +optional
|
||||
TargetNamespace string `json:"targetNamespace,omitempty"`
|
||||
|
||||
// TokenSecretName holds the master access token clients present to the API.
|
||||
// The operator generates a token if the Secret does not exist, so a
|
||||
// VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||
// +optional
|
||||
TokenSecretName string `json:"tokenSecretName,omitempty"`
|
||||
|
||||
// Env are extra environment variables rendered into the API ConfigMap.
|
||||
// +optional
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
|
||||
// Service controls how the API is exposed (defaults to ClusterIP).
|
||||
// +optional
|
||||
Service ClusterServiceSpec `json:"service,omitempty"`
|
||||
|
||||
// Resources for the API container.
|
||||
// +optional
|
||||
Resources corev1.ResourceRequirements `json:"resources,omitempty"`
|
||||
}
|
||||
|
||||
// BindTSIGAPIStatus reports observed API state.
|
||||
type BindTSIGAPIStatus struct {
|
||||
// +optional
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// +optional
|
||||
ReadyReplicas int32 `json:"readyReplicas,omitempty"`
|
||||
// Endpoint is the in-cluster URL clients (Vault) use to reach the API.
|
||||
// +optional
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
// TokenSecret is the Secret holding the master access token.
|
||||
// +optional
|
||||
TokenSecret string `json:"tokenSecret,omitempty"`
|
||||
// +optional
|
||||
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
|
||||
// +optional
|
||||
// +listType=map
|
||||
// +listMapKey=type
|
||||
Conditions []metav1.Condition `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
// +kubebuilder:subresource:status
|
||||
// +kubebuilder:resource:shortName=btapi
|
||||
// +kubebuilder:printcolumn:name="Endpoint",type=string,JSONPath=`.status.endpoint`
|
||||
// +kubebuilder:printcolumn:name="Ready",type=integer,JSONPath=`.status.readyReplicas`
|
||||
// +kubebuilder:printcolumn:name="Phase",type=string,JSONPath=`.status.phase`
|
||||
|
||||
// BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||
// reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||
type BindTSIGAPI struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ObjectMeta `json:"metadata,omitempty"`
|
||||
|
||||
Spec BindTSIGAPISpec `json:"spec,omitempty"`
|
||||
Status BindTSIGAPIStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
// +kubebuilder:object:root=true
|
||||
|
||||
// BindTSIGAPIList contains a list of BindTSIGAPI.
|
||||
type BindTSIGAPIList struct {
|
||||
metav1.TypeMeta `json:",inline"`
|
||||
metav1.ListMeta `json:"metadata,omitempty"`
|
||||
Items []BindTSIGAPI `json:"items"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
SchemeBuilder.Register(&BindTSIGAPI{}, &BindTSIGAPIList{})
|
||||
}
|
||||
@@ -581,6 +581,111 @@ func (in *BindPolicyStatus) DeepCopy() *BindPolicyStatus {
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGAPI) DeepCopyInto(out *BindTSIGAPI) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
|
||||
in.Spec.DeepCopyInto(&out.Spec)
|
||||
in.Status.DeepCopyInto(&out.Status)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPI.
|
||||
func (in *BindTSIGAPI) DeepCopy() *BindTSIGAPI {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BindTSIGAPI)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *BindTSIGAPI) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGAPIList) DeepCopyInto(out *BindTSIGAPIList) {
|
||||
*out = *in
|
||||
out.TypeMeta = in.TypeMeta
|
||||
in.ListMeta.DeepCopyInto(&out.ListMeta)
|
||||
if in.Items != nil {
|
||||
in, out := &in.Items, &out.Items
|
||||
*out = make([]BindTSIGAPI, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIList.
|
||||
func (in *BindTSIGAPIList) DeepCopy() *BindTSIGAPIList {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BindTSIGAPIList)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
|
||||
func (in *BindTSIGAPIList) DeepCopyObject() runtime.Object {
|
||||
if c := in.DeepCopy(); c != nil {
|
||||
return c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGAPISpec) DeepCopyInto(out *BindTSIGAPISpec) {
|
||||
*out = *in
|
||||
if in.Env != nil {
|
||||
in, out := &in.Env, &out.Env
|
||||
*out = make(map[string]string, len(*in))
|
||||
for key, val := range *in {
|
||||
(*out)[key] = val
|
||||
}
|
||||
}
|
||||
in.Service.DeepCopyInto(&out.Service)
|
||||
in.Resources.DeepCopyInto(&out.Resources)
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPISpec.
|
||||
func (in *BindTSIGAPISpec) DeepCopy() *BindTSIGAPISpec {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BindTSIGAPISpec)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGAPIStatus) DeepCopyInto(out *BindTSIGAPIStatus) {
|
||||
*out = *in
|
||||
if in.Conditions != nil {
|
||||
in, out := &in.Conditions, &out.Conditions
|
||||
*out = make([]v1.Condition, len(*in))
|
||||
for i := range *in {
|
||||
(*in)[i].DeepCopyInto(&(*out)[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new BindTSIGAPIStatus.
|
||||
func (in *BindTSIGAPIStatus) DeepCopy() *BindTSIGAPIStatus {
|
||||
if in == nil {
|
||||
return nil
|
||||
}
|
||||
out := new(BindTSIGAPIStatus)
|
||||
in.DeepCopyInto(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
|
||||
func (in *BindTSIGKey) DeepCopyInto(out *BindTSIGKey) {
|
||||
*out = *in
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log/zap"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
"git.unkin.net/unkin/bind-operator/internal/tsigapi"
|
||||
)
|
||||
|
||||
var scheme = runtime.NewScheme()
|
||||
|
||||
func init() {
|
||||
utilruntime.Must(clientgoscheme.AddToScheme(scheme))
|
||||
utilruntime.Must(bindv1alpha1.AddToScheme(scheme))
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctrl.SetLogger(zap.New(zap.UseDevMode(false)))
|
||||
logger := ctrl.Log.WithName("tsig-api")
|
||||
|
||||
addr := envOr("LISTEN_ADDR", ":8443")
|
||||
namespace := envOr("TARGET_NAMESPACE", currentNamespace())
|
||||
token := os.Getenv("API_TOKEN")
|
||||
|
||||
c, err := client.New(ctrl.GetConfigOrDie(), client.Options{Scheme: scheme})
|
||||
if err != nil {
|
||||
logger.Error(err, "unable to build kubernetes client")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
srv := &tsigapi.Server{
|
||||
Client: c,
|
||||
Namespace: namespace,
|
||||
Token: token,
|
||||
Log: logger,
|
||||
WaitTimeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
httpSrv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: srv.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
logger.Info("starting tsig api", "addr", addr, "namespace", namespace)
|
||||
if err := httpSrv.ListenAndServe(); err != nil {
|
||||
logger.Error(err, "tsig api exited")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func currentNamespace() string {
|
||||
if b, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace"); err == nil {
|
||||
return string(b)
|
||||
}
|
||||
return "bind-system"
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.17.3
|
||||
name: bindtsigapis.bind.unkin.net
|
||||
spec:
|
||||
group: bind.unkin.net
|
||||
names:
|
||||
kind: BindTSIGAPI
|
||||
listKind: BindTSIGAPIList
|
||||
plural: bindtsigapis
|
||||
shortNames:
|
||||
- btapi
|
||||
singular: bindtsigapi
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .status.endpoint
|
||||
name: Endpoint
|
||||
type: string
|
||||
- jsonPath: .status.readyReplicas
|
||||
name: Ready
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: |-
|
||||
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: |-
|
||||
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||
custom resources, which the operator then reconciles into key material.
|
||||
properties:
|
||||
env:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Env are extra environment variables rendered into the
|
||||
API ConfigMap.
|
||||
type: object
|
||||
image:
|
||||
default: git.unkin.net/unkin/bind-tsig-api:latest
|
||||
description: Image is the companion API container image.
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: ImagePullPolicy for the API container.
|
||||
type: string
|
||||
port:
|
||||
default: 8443
|
||||
description: Port the API listens on. Defaults to 8443.
|
||||
format: int32
|
||||
type: integer
|
||||
replicas:
|
||||
default: 1
|
||||
description: Replicas of the API. Defaults to 1.
|
||||
format: int32
|
||||
type: integer
|
||||
resources:
|
||||
description: Resources for the API container.
|
||||
properties:
|
||||
claims:
|
||||
description: |-
|
||||
Claims lists the names of resources, defined in spec.resourceClaims,
|
||||
that are used by this container.
|
||||
|
||||
This field depends on the
|
||||
DynamicResourceAllocation feature gate.
|
||||
|
||||
This field is immutable. It can only be set for containers.
|
||||
items:
|
||||
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
|
||||
properties:
|
||||
name:
|
||||
description: |-
|
||||
Name must match the name of one entry in pod.spec.resourceClaims of
|
||||
the Pod where this field is used. It makes that resource available
|
||||
inside a container.
|
||||
type: string
|
||||
request:
|
||||
description: |-
|
||||
Request is the name chosen for a request in the referenced claim.
|
||||
If empty, everything from the claim is made available, otherwise
|
||||
only the result of this request.
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- name
|
||||
x-kubernetes-list-type: map
|
||||
limits:
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||
x-kubernetes-int-or-string: true
|
||||
description: |-
|
||||
Limits describes the maximum amount of compute resources allowed.
|
||||
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||
type: object
|
||||
requests:
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||
x-kubernetes-int-or-string: true
|
||||
description: |-
|
||||
Requests describes the minimum amount of compute resources required.
|
||||
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
|
||||
otherwise to an implementation-defined value. Requests cannot exceed Limits.
|
||||
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||
type: object
|
||||
type: object
|
||||
service:
|
||||
description: Service controls how the API is exposed (defaults to
|
||||
ClusterIP).
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Annotations added to the client-facing Service (e.g.
|
||||
PureLB/MetalLB hints).
|
||||
type: object
|
||||
externalTrafficPolicy:
|
||||
description: |-
|
||||
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||
only routes to nodes running a pod. Defaults to Cluster.
|
||||
enum:
|
||||
- Cluster
|
||||
- Local
|
||||
type: string
|
||||
loadBalancerIP:
|
||||
description: LoadBalancerIP requests a specific address when Type
|
||||
is LoadBalancer.
|
||||
type: string
|
||||
type:
|
||||
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||
enum:
|
||||
- ClusterIP
|
||||
- LoadBalancer
|
||||
- NodePort
|
||||
type: string
|
||||
type: object
|
||||
targetNamespace:
|
||||
description: |-
|
||||
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||
to the API's own namespace.
|
||||
type: string
|
||||
tokenSecretName:
|
||||
description: |-
|
||||
TokenSecretName holds the master access token clients present to the API.
|
||||
The operator generates a token if the Secret does not exist, so a
|
||||
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||
type: string
|
||||
type: object
|
||||
status:
|
||||
description: BindTSIGAPIStatus reports observed API state.
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
endpoint:
|
||||
description: Endpoint is the in-cluster URL clients (Vault) use to
|
||||
reach the API.
|
||||
type: string
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
readyReplicas:
|
||||
format: int32
|
||||
type: integer
|
||||
tokenSecret:
|
||||
description: TokenSecret is the Secret holding the master access token.
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
@@ -2023,6 +2023,273 @@ spec:
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.17.3
|
||||
name: bindtsigapis.bind.unkin.net
|
||||
spec:
|
||||
group: bind.unkin.net
|
||||
names:
|
||||
kind: BindTSIGAPI
|
||||
listKind: BindTSIGAPIList
|
||||
plural: bindtsigapis
|
||||
shortNames:
|
||||
- btapi
|
||||
singular: bindtsigapi
|
||||
scope: Namespaced
|
||||
versions:
|
||||
- additionalPrinterColumns:
|
||||
- jsonPath: .status.endpoint
|
||||
name: Endpoint
|
||||
type: string
|
||||
- jsonPath: .status.readyReplicas
|
||||
name: Ready
|
||||
type: integer
|
||||
- jsonPath: .status.phase
|
||||
name: Phase
|
||||
type: string
|
||||
name: v1alpha1
|
||||
schema:
|
||||
openAPIV3Schema:
|
||||
description: |-
|
||||
BindTSIGAPI deploys the companion TSIG API. Creating one makes the operator
|
||||
reconcile a Deployment, Service, ConfigMap, token Secret and RBAC for it.
|
||||
properties:
|
||||
apiVersion:
|
||||
description: |-
|
||||
APIVersion defines the versioned schema of this representation of an object.
|
||||
Servers should convert recognized schemas to the latest internal value, and
|
||||
may reject unrecognized values.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
|
||||
type: string
|
||||
kind:
|
||||
description: |-
|
||||
Kind is a string value representing the REST resource this object represents.
|
||||
Servers may infer this from the endpoint the client submits requests to.
|
||||
Cannot be updated.
|
||||
In CamelCase.
|
||||
More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
|
||||
type: string
|
||||
metadata:
|
||||
type: object
|
||||
spec:
|
||||
description: |-
|
||||
BindTSIGAPISpec configures the companion TSIG API that the operator deploys.
|
||||
The API exposes an HTTP contract (used by vault-plugin-secrets-bind-tsig) for
|
||||
creating, rotating and deleting TSIG keys; it does so by managing BindTSIGKey
|
||||
custom resources, which the operator then reconciles into key material.
|
||||
properties:
|
||||
env:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Env are extra environment variables rendered into the
|
||||
API ConfigMap.
|
||||
type: object
|
||||
image:
|
||||
default: git.unkin.net/unkin/bind-tsig-api:latest
|
||||
description: Image is the companion API container image.
|
||||
type: string
|
||||
imagePullPolicy:
|
||||
description: ImagePullPolicy for the API container.
|
||||
type: string
|
||||
port:
|
||||
default: 8443
|
||||
description: Port the API listens on. Defaults to 8443.
|
||||
format: int32
|
||||
type: integer
|
||||
replicas:
|
||||
default: 1
|
||||
description: Replicas of the API. Defaults to 1.
|
||||
format: int32
|
||||
type: integer
|
||||
resources:
|
||||
description: Resources for the API container.
|
||||
properties:
|
||||
claims:
|
||||
description: |-
|
||||
Claims lists the names of resources, defined in spec.resourceClaims,
|
||||
that are used by this container.
|
||||
|
||||
This field depends on the
|
||||
DynamicResourceAllocation feature gate.
|
||||
|
||||
This field is immutable. It can only be set for containers.
|
||||
items:
|
||||
description: ResourceClaim references one entry in PodSpec.ResourceClaims.
|
||||
properties:
|
||||
name:
|
||||
description: |-
|
||||
Name must match the name of one entry in pod.spec.resourceClaims of
|
||||
the Pod where this field is used. It makes that resource available
|
||||
inside a container.
|
||||
type: string
|
||||
request:
|
||||
description: |-
|
||||
Request is the name chosen for a request in the referenced claim.
|
||||
If empty, everything from the claim is made available, otherwise
|
||||
only the result of this request.
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- name
|
||||
x-kubernetes-list-type: map
|
||||
limits:
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||
x-kubernetes-int-or-string: true
|
||||
description: |-
|
||||
Limits describes the maximum amount of compute resources allowed.
|
||||
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||
type: object
|
||||
requests:
|
||||
additionalProperties:
|
||||
anyOf:
|
||||
- type: integer
|
||||
- type: string
|
||||
pattern: ^(\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))(([KMGTPE]i)|[numkMGTPE]|([eE](\+|-)?(([0-9]+(\.[0-9]*)?)|(\.[0-9]+))))?$
|
||||
x-kubernetes-int-or-string: true
|
||||
description: |-
|
||||
Requests describes the minimum amount of compute resources required.
|
||||
If Requests is omitted for a container, it defaults to Limits if that is explicitly specified,
|
||||
otherwise to an implementation-defined value. Requests cannot exceed Limits.
|
||||
More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/
|
||||
type: object
|
||||
type: object
|
||||
service:
|
||||
description: Service controls how the API is exposed (defaults to
|
||||
ClusterIP).
|
||||
properties:
|
||||
annotations:
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: Annotations added to the client-facing Service (e.g.
|
||||
PureLB/MetalLB hints).
|
||||
type: object
|
||||
externalTrafficPolicy:
|
||||
description: |-
|
||||
ExternalTrafficPolicy for a LoadBalancer/NodePort Service. Local preserves
|
||||
client source IPs (required for source-IP ACLs on the DNS servers) but
|
||||
only routes to nodes running a pod. Defaults to Cluster.
|
||||
enum:
|
||||
- Cluster
|
||||
- Local
|
||||
type: string
|
||||
loadBalancerIP:
|
||||
description: LoadBalancerIP requests a specific address when Type
|
||||
is LoadBalancer.
|
||||
type: string
|
||||
type:
|
||||
description: Type of the client-facing Service. Defaults to ClusterIP.
|
||||
enum:
|
||||
- ClusterIP
|
||||
- LoadBalancer
|
||||
- NodePort
|
||||
type: string
|
||||
type: object
|
||||
targetNamespace:
|
||||
description: |-
|
||||
TargetNamespace is where the API creates BindTSIGKey resources. Defaults
|
||||
to the API's own namespace.
|
||||
type: string
|
||||
tokenSecretName:
|
||||
description: |-
|
||||
TokenSecretName holds the master access token clients present to the API.
|
||||
The operator generates a token if the Secret does not exist, so a
|
||||
VaultStaticSecret may pre-seed it instead. Defaults to "<name>-token".
|
||||
type: string
|
||||
type: object
|
||||
status:
|
||||
description: BindTSIGAPIStatus reports observed API state.
|
||||
properties:
|
||||
conditions:
|
||||
items:
|
||||
description: Condition contains details for one aspect of the current
|
||||
state of this API Resource.
|
||||
properties:
|
||||
lastTransitionTime:
|
||||
description: |-
|
||||
lastTransitionTime is the last time the condition transitioned from one status to another.
|
||||
This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable.
|
||||
format: date-time
|
||||
type: string
|
||||
message:
|
||||
description: |-
|
||||
message is a human readable message indicating details about the transition.
|
||||
This may be an empty string.
|
||||
maxLength: 32768
|
||||
type: string
|
||||
observedGeneration:
|
||||
description: |-
|
||||
observedGeneration represents the .metadata.generation that the condition was set based upon.
|
||||
For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date
|
||||
with respect to the current state of the instance.
|
||||
format: int64
|
||||
minimum: 0
|
||||
type: integer
|
||||
reason:
|
||||
description: |-
|
||||
reason contains a programmatic identifier indicating the reason for the condition's last transition.
|
||||
Producers of specific condition types may define expected values and meanings for this field,
|
||||
and whether the values are considered a guaranteed API.
|
||||
The value should be a CamelCase string.
|
||||
This field may not be empty.
|
||||
maxLength: 1024
|
||||
minLength: 1
|
||||
pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$
|
||||
type: string
|
||||
status:
|
||||
description: status of the condition, one of True, False, Unknown.
|
||||
enum:
|
||||
- "True"
|
||||
- "False"
|
||||
- Unknown
|
||||
type: string
|
||||
type:
|
||||
description: type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||
maxLength: 316
|
||||
pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$
|
||||
type: string
|
||||
required:
|
||||
- lastTransitionTime
|
||||
- message
|
||||
- reason
|
||||
- status
|
||||
- type
|
||||
type: object
|
||||
type: array
|
||||
x-kubernetes-list-map-keys:
|
||||
- type
|
||||
x-kubernetes-list-type: map
|
||||
endpoint:
|
||||
description: Endpoint is the in-cluster URL clients (Vault) use to
|
||||
reach the API.
|
||||
type: string
|
||||
observedGeneration:
|
||||
format: int64
|
||||
type: integer
|
||||
phase:
|
||||
type: string
|
||||
readyReplicas:
|
||||
format: int32
|
||||
type: integer
|
||||
tokenSecret:
|
||||
description: TokenSecret is the Secret holding the master access token.
|
||||
type: string
|
||||
type: object
|
||||
type: object
|
||||
served: true
|
||||
storage: true
|
||||
subresources:
|
||||
status: {}
|
||||
---
|
||||
apiVersion: apiextensions.k8s.io/v1
|
||||
kind: CustomResourceDefinition
|
||||
metadata:
|
||||
annotations:
|
||||
controller-gen.kubebuilder.io/version: v0.17.3
|
||||
|
||||
@@ -9,6 +9,7 @@ rules:
|
||||
resources:
|
||||
- configmaps
|
||||
- secrets
|
||||
- serviceaccounts
|
||||
- services
|
||||
verbs:
|
||||
- create
|
||||
@@ -36,6 +37,7 @@ rules:
|
||||
- apiGroups:
|
||||
- apps
|
||||
resources:
|
||||
- deployments
|
||||
- statefulsets
|
||||
verbs:
|
||||
- create
|
||||
@@ -53,6 +55,7 @@ rules:
|
||||
- bindclusters
|
||||
- binddnssecpolicies
|
||||
- bindpolicies
|
||||
- bindtsigapis
|
||||
- bindtsigkeys
|
||||
- bindviews
|
||||
- bindzones
|
||||
@@ -73,6 +76,7 @@ rules:
|
||||
- bindclusters/status
|
||||
- binddnssecpolicies/status
|
||||
- bindpolicies/status
|
||||
- bindtsigapis/status
|
||||
- bindtsigkeys/status
|
||||
- bindviews/status
|
||||
- bindzones/status
|
||||
@@ -81,3 +85,16 @@ rules:
|
||||
- get
|
||||
- patch
|
||||
- update
|
||||
- apiGroups:
|
||||
- rbac.authorization.k8s.io
|
||||
resources:
|
||||
- rolebindings
|
||||
- roles
|
||||
verbs:
|
||||
- create
|
||||
- delete
|
||||
- get
|
||||
- list
|
||||
- patch
|
||||
- update
|
||||
- watch
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
appsv1 "k8s.io/api/apps/v1"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
rbacv1 "k8s.io/api/rbac/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
ctrl "sigs.k8s.io/controller-runtime"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/log"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
"git.unkin.net/unkin/bind-operator/internal/bind"
|
||||
)
|
||||
|
||||
// BindTSIGAPIReconciler deploys the companion TSIG API (Deployment, Service,
|
||||
// ConfigMap, token Secret and RBAC) when a BindTSIGAPI resource exists.
|
||||
type BindTSIGAPIReconciler struct {
|
||||
client.Client
|
||||
Scheme *runtime.Scheme
|
||||
}
|
||||
|
||||
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=bind.unkin.net,resources=bindtsigapis/status,verbs=get;update;patch
|
||||
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups="",resources=serviceaccounts,verbs=get;list;watch;create;update;patch;delete
|
||||
// +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles;rolebindings,verbs=get;list;watch;create;update;patch;delete
|
||||
|
||||
func (r *BindTSIGAPIReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
|
||||
logger := log.FromContext(ctx)
|
||||
|
||||
var api bindv1alpha1.BindTSIGAPI
|
||||
if err := r.Get(ctx, req.NamespacedName, &api); err != nil {
|
||||
return ctrl.Result{}, client.IgnoreNotFound(err)
|
||||
}
|
||||
|
||||
tokenSecret := api.Spec.TokenSecretName
|
||||
if tokenSecret == "" {
|
||||
tokenSecret = api.Name + "-token"
|
||||
}
|
||||
targetNS := api.Spec.TargetNamespace
|
||||
if targetNS == "" {
|
||||
targetNS = api.Namespace
|
||||
}
|
||||
|
||||
for _, step := range []func(context.Context, *bindv1alpha1.BindTSIGAPI, string, string) error{
|
||||
r.reconcileServiceAccount,
|
||||
r.reconcileRBAC,
|
||||
r.reconcileTokenSecret,
|
||||
r.reconcileConfigMap,
|
||||
r.reconcileDeployment,
|
||||
r.reconcileService,
|
||||
} {
|
||||
if err := step(ctx, &api, tokenSecret, targetNS); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Status from the Deployment.
|
||||
var dep appsv1.Deployment
|
||||
_ = r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: api.Name}, &dep)
|
||||
port := api.Spec.Port
|
||||
if port == 0 {
|
||||
port = 8443
|
||||
}
|
||||
api.Status.ReadyReplicas = dep.Status.ReadyReplicas
|
||||
api.Status.Endpoint = fmt.Sprintf("http://%s.%s.svc:%d", api.Name, api.Namespace, port)
|
||||
api.Status.TokenSecret = tokenSecret
|
||||
api.Status.ObservedGeneration = api.Generation
|
||||
ready := dep.Status.ReadyReplicas > 0
|
||||
if ready {
|
||||
api.Status.Phase = "Ready"
|
||||
} else {
|
||||
api.Status.Phase = "Progressing"
|
||||
}
|
||||
setReady(&api.Status.Conditions, api.Generation, ready, "Reconciled", fmt.Sprintf("%d ready", dep.Status.ReadyReplicas))
|
||||
if err := r.Status().Update(ctx, &api); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
if !ready {
|
||||
return ctrl.Result{RequeueAfter: requeueShort}, nil
|
||||
}
|
||||
logger.V(1).Info("tsig api reconciled", "api", api.Name)
|
||||
return ctrl.Result{}, nil
|
||||
}
|
||||
|
||||
func tsigAPILabels(name string) map[string]string {
|
||||
return map[string]string{
|
||||
managedByLabel: managedByValue,
|
||||
"app.kubernetes.io/name": "bind-tsig-api",
|
||||
"app.kubernetes.io/instance": name,
|
||||
"app.kubernetes.io/component": "tsig-api",
|
||||
}
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) reconcileServiceAccount(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
|
||||
sa := &corev1.ServiceAccount{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||
return r.apply(ctx, api, sa, func() {})
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) reconcileRBAC(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
|
||||
role := &rbacv1.Role{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
|
||||
if err := r.applyIn(ctx, api, role, targetNS, func() {
|
||||
role.Rules = []rbacv1.PolicyRule{
|
||||
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys"}, Verbs: []string{"get", "list", "watch", "create", "update", "patch", "delete"}},
|
||||
{APIGroups: []string{"bind.unkin.net"}, Resources: []string{"bindtsigkeys/status"}, Verbs: []string{"get"}},
|
||||
{APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch", "delete"}},
|
||||
}
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
rb := &rbacv1.RoleBinding{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: targetNS, Labels: tsigAPILabels(api.Name)}}
|
||||
return r.applyIn(ctx, api, rb, targetNS, func() {
|
||||
rb.RoleRef = rbacv1.RoleRef{APIGroup: "rbac.authorization.k8s.io", Kind: "Role", Name: api.Name}
|
||||
rb.Subjects = []rbacv1.Subject{{Kind: "ServiceAccount", Name: api.Name, Namespace: api.Namespace}}
|
||||
})
|
||||
}
|
||||
|
||||
// reconcileTokenSecret creates the master-access-token Secret only when it does
|
||||
// not already exist, so a VaultStaticSecret may pre-seed it. Not owned by the
|
||||
// BindTSIGAPI, so it survives and stays overwritable.
|
||||
func (r *BindTSIGAPIReconciler) reconcileTokenSecret(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
|
||||
var existing corev1.Secret
|
||||
err := r.Get(ctx, types.NamespacedName{Namespace: api.Namespace, Name: tokenSecret}, &existing)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
token, genErr := bind.GenerateSecret(32)
|
||||
if genErr != nil {
|
||||
return genErr
|
||||
}
|
||||
s := &corev1.Secret{
|
||||
ObjectMeta: metav1.ObjectMeta{Name: tokenSecret, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)},
|
||||
Data: map[string][]byte{"token": []byte(token)},
|
||||
}
|
||||
return r.Create(ctx, s)
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) reconcileConfigMap(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, targetNS string) error {
|
||||
cm := &corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Name: api.Name + "-config", Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||
return r.apply(ctx, api, cm, func() {
|
||||
port := api.Spec.Port
|
||||
if port == 0 {
|
||||
port = 8443
|
||||
}
|
||||
data := map[string]string{
|
||||
"LISTEN_ADDR": fmt.Sprintf(":%d", port),
|
||||
"TARGET_NAMESPACE": targetNS,
|
||||
}
|
||||
for k, v := range api.Spec.Env {
|
||||
data[k] = v
|
||||
}
|
||||
cm.Data = data
|
||||
})
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) reconcileDeployment(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, tokenSecret, _ string) error {
|
||||
dep := &appsv1.Deployment{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name)}}
|
||||
return r.apply(ctx, api, dep, func() {
|
||||
replicas := api.Spec.Replicas
|
||||
if replicas == 0 {
|
||||
replicas = 1
|
||||
}
|
||||
image := api.Spec.Image
|
||||
if image == "" {
|
||||
image = "git.unkin.net/unkin/bind-tsig-api:latest"
|
||||
}
|
||||
port := api.Spec.Port
|
||||
if port == 0 {
|
||||
port = 8443
|
||||
}
|
||||
labels := tsigAPILabels(api.Name)
|
||||
dep.Spec = appsv1.DeploymentSpec{
|
||||
Replicas: &replicas,
|
||||
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app.kubernetes.io/instance": api.Name}},
|
||||
Template: corev1.PodTemplateSpec{
|
||||
ObjectMeta: metav1.ObjectMeta{Labels: labels},
|
||||
Spec: corev1.PodSpec{
|
||||
ServiceAccountName: api.Name,
|
||||
SecurityContext: &corev1.PodSecurityContext{RunAsNonRoot: ptr(true)},
|
||||
Containers: []corev1.Container{{
|
||||
Name: "tsig-api",
|
||||
Image: image,
|
||||
ImagePullPolicy: api.Spec.ImagePullPolicy,
|
||||
Command: []string{"tsig-api"},
|
||||
Ports: []corev1.ContainerPort{{Name: "https", ContainerPort: port}},
|
||||
EnvFrom: []corev1.EnvFromSource{{ConfigMapRef: &corev1.ConfigMapEnvSource{LocalObjectReference: corev1.LocalObjectReference{Name: api.Name + "-config"}}}},
|
||||
Env: []corev1.EnvVar{{
|
||||
Name: "API_TOKEN",
|
||||
ValueFrom: &corev1.EnvVarSource{SecretKeyRef: &corev1.SecretKeySelector{LocalObjectReference: corev1.LocalObjectReference{Name: tokenSecret}, Key: "token"}},
|
||||
}},
|
||||
Resources: api.Spec.Resources,
|
||||
ReadinessProbe: &corev1.Probe{
|
||||
ProbeHandler: corev1.ProbeHandler{HTTPGet: &corev1.HTTPGetAction{Path: "/healthz", Port: intstrFromInt(int(port))}},
|
||||
InitialDelaySeconds: 5, PeriodSeconds: 10,
|
||||
},
|
||||
SecurityContext: &corev1.SecurityContext{
|
||||
AllowPrivilegeEscalation: ptr(false),
|
||||
ReadOnlyRootFilesystem: ptr(true),
|
||||
Capabilities: &corev1.Capabilities{Drop: []corev1.Capability{"ALL"}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) reconcileService(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, _, _ string) error {
|
||||
svc := &corev1.Service{ObjectMeta: metav1.ObjectMeta{Name: api.Name, Namespace: api.Namespace, Labels: tsigAPILabels(api.Name), Annotations: api.Spec.Service.Annotations}}
|
||||
return r.apply(ctx, api, svc, func() {
|
||||
port := api.Spec.Port
|
||||
if port == 0 {
|
||||
port = 8443
|
||||
}
|
||||
svcType := api.Spec.Service.Type
|
||||
if svcType == "" {
|
||||
svcType = corev1.ServiceTypeClusterIP
|
||||
}
|
||||
svc.Spec.Type = svcType
|
||||
svc.Spec.Selector = map[string]string{"app.kubernetes.io/instance": api.Name}
|
||||
svc.Spec.Ports = []corev1.ServicePort{{Name: "https", Port: port, TargetPort: intstrFromInt(int(port))}}
|
||||
if api.Spec.Service.LoadBalancerIP != "" {
|
||||
svc.Spec.LoadBalancerIP = api.Spec.Service.LoadBalancerIP
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// apply creates or updates an owned object (in the BindTSIGAPI's namespace).
|
||||
func (r *BindTSIGAPIReconciler) apply(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, mutate func()) error {
|
||||
return r.applyIn(ctx, api, obj, api.Namespace, mutate)
|
||||
}
|
||||
|
||||
// applyIn creates or updates an object; ownership is set only for objects in
|
||||
// the BindTSIGAPI's own namespace (cross-namespace owner refs are not allowed).
|
||||
func (r *BindTSIGAPIReconciler) applyIn(ctx context.Context, api *bindv1alpha1.BindTSIGAPI, obj client.Object, namespace string, mutate func()) error {
|
||||
key := client.ObjectKeyFromObject(obj)
|
||||
err := r.Get(ctx, key, obj)
|
||||
if apierrors.IsNotFound(err) {
|
||||
mutate()
|
||||
if namespace == api.Namespace {
|
||||
if serr := ctrl.SetControllerReference(api, obj, r.Scheme); serr != nil {
|
||||
return serr
|
||||
}
|
||||
}
|
||||
return r.Create(ctx, obj)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mutate()
|
||||
return r.Update(ctx, obj)
|
||||
}
|
||||
|
||||
func (r *BindTSIGAPIReconciler) SetupWithManager(mgr ctrl.Manager) error {
|
||||
return ctrl.NewControllerManagedBy(mgr).
|
||||
For(&bindv1alpha1.BindTSIGAPI{}).
|
||||
Owns(&appsv1.Deployment{}).
|
||||
Owns(&corev1.Service{}).
|
||||
Owns(&corev1.ConfigMap{}).
|
||||
Owns(&corev1.ServiceAccount{}).
|
||||
Complete(r)
|
||||
}
|
||||
|
||||
func ptr[T any](v T) *T { return &v }
|
||||
@@ -35,5 +35,8 @@ func SetupAll(mgr ctrl.Manager, exec *bind.Executor) error {
|
||||
if err := (&DNSRecordReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme(), Exec: exec}).SetupWithManager(mgr); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := (&BindTSIGAPIReconciler{Client: mgr.GetClient(), Scheme: mgr.GetScheme()}).SetupWithManager(mgr); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package tsigapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
// createKey creates a BindTSIGKey and waits for the operator to materialise it.
|
||||
func (s *Server) createKey(ctx context.Context, req createRequest) (*keyResponse, error) {
|
||||
algorithm := req.Algorithm
|
||||
if algorithm == "" {
|
||||
algorithm = string(bindv1alpha1.TSIGHMACSHA256)
|
||||
}
|
||||
key := &bindv1alpha1.BindTSIGKey{
|
||||
ObjectMeta: metav1.ObjectMeta{
|
||||
Name: req.Name,
|
||||
Namespace: s.Namespace,
|
||||
Labels: map[string]string{"app.kubernetes.io/managed-by": "bind-tsig-api"},
|
||||
},
|
||||
Spec: bindv1alpha1.BindTSIGKeySpec{
|
||||
Algorithm: bindv1alpha1.TSIGAlgorithm(algorithm),
|
||||
ClusterRef: req.ClusterRef,
|
||||
KeyName: req.Name,
|
||||
},
|
||||
}
|
||||
if err := s.Client.Create(ctx, key); err != nil && !apierrors.IsAlreadyExists(err) {
|
||||
return nil, fmt.Errorf("create bindtsigkey: %w", err)
|
||||
}
|
||||
return s.waitForMaterial(ctx, req.Name)
|
||||
}
|
||||
|
||||
// readKey returns the current material for a key.
|
||||
func (s *Server) readKey(ctx context.Context, name string) (*keyResponse, error) {
|
||||
var key bindv1alpha1.BindTSIGKey
|
||||
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.material(ctx, &key)
|
||||
}
|
||||
|
||||
// rotateKey replaces the key material: it deletes the key Secret (the operator
|
||||
// regenerates it) and bumps a rotation annotation so the cluster controller
|
||||
// re-renders keys.conf and reloads BIND.
|
||||
func (s *Server) rotateKey(ctx context.Context, name string) (*keyResponse, error) {
|
||||
var key bindv1alpha1.BindTSIGKey
|
||||
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secretName := secretNameFor(&key)
|
||||
var secret corev1.Secret
|
||||
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretName}, &secret); err == nil {
|
||||
if derr := s.Client.Delete(ctx, &secret); derr != nil && !apierrors.IsNotFound(derr) {
|
||||
return nil, fmt.Errorf("delete key secret: %w", derr)
|
||||
}
|
||||
} else if !apierrors.IsNotFound(err) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Trigger re-generation + re-render by bumping the CR.
|
||||
if key.Annotations == nil {
|
||||
key.Annotations = map[string]string{}
|
||||
}
|
||||
key.Annotations[rotationAnnotation] = s.Now().UTC().Format(time.RFC3339Nano)
|
||||
if err := s.Client.Update(ctx, &key); err != nil {
|
||||
return nil, fmt.Errorf("bump rotation annotation: %w", err)
|
||||
}
|
||||
|
||||
return s.waitForNewMaterial(ctx, name, secret.UID)
|
||||
}
|
||||
|
||||
// deleteKey removes the BindTSIGKey (its Secret is garbage-collected).
|
||||
func (s *Server) deleteKey(ctx context.Context, name string) error {
|
||||
key := &bindv1alpha1.BindTSIGKey{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: s.Namespace}}
|
||||
if err := s.Client.Delete(ctx, key); err != nil && !apierrors.IsNotFound(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// material reads the key material from the Secret referenced by a BindTSIGKey.
|
||||
func (s *Server) material(ctx context.Context, key *bindv1alpha1.BindTSIGKey) (*keyResponse, error) {
|
||||
var secret corev1.Secret
|
||||
if err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(key)}, &secret); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
algorithm := string(secret.Data["algorithm"])
|
||||
if algorithm == "" {
|
||||
algorithm = string(key.Spec.Algorithm)
|
||||
}
|
||||
keyName := string(secret.Data["keyName"])
|
||||
if keyName == "" {
|
||||
keyName = key.Name
|
||||
}
|
||||
return &keyResponse{
|
||||
Name: key.Name,
|
||||
Algorithm: algorithm,
|
||||
Secret: string(secret.Data["secret"]),
|
||||
KeyName: keyName,
|
||||
ClusterRef: key.Spec.ClusterRef,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// waitForMaterial polls until the key's Secret exists (operator reconciled).
|
||||
func (s *Server) waitForMaterial(ctx context.Context, name string) (*keyResponse, error) {
|
||||
deadline := s.Now().Add(s.WaitTimeout)
|
||||
for {
|
||||
var key bindv1alpha1.BindTSIGKey
|
||||
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp, err := s.material(ctx, &key); err == nil && resp.Secret != "" {
|
||||
return resp, nil
|
||||
}
|
||||
if s.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("timed out waiting for key %q material", name)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// waitForNewMaterial polls until the Secret has been recreated (different UID).
|
||||
func (s *Server) waitForNewMaterial(ctx context.Context, name string, oldUID types.UID) (*keyResponse, error) {
|
||||
deadline := s.Now().Add(s.WaitTimeout)
|
||||
for {
|
||||
var key bindv1alpha1.BindTSIGKey
|
||||
if err := s.Client.Get(ctx, s.key(name), &key); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var secret corev1.Secret
|
||||
err := s.Client.Get(ctx, client.ObjectKey{Namespace: s.Namespace, Name: secretNameFor(&key)}, &secret)
|
||||
if err == nil && secret.UID != oldUID && len(secret.Data["secret"]) > 0 {
|
||||
return s.material(ctx, &key)
|
||||
}
|
||||
if s.Now().After(deadline) {
|
||||
return nil, fmt.Errorf("timed out waiting for key %q to rotate", name)
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(250 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// secretNameFor returns the Secret name holding a key's material.
|
||||
func secretNameFor(key *bindv1alpha1.BindTSIGKey) string {
|
||||
if key.Status.SecretName != "" {
|
||||
return key.Status.SecretName
|
||||
}
|
||||
if key.Spec.SecretName != "" {
|
||||
return key.Spec.SecretName
|
||||
}
|
||||
return key.Name + "-tsig"
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
// Package tsigapi implements the companion TSIG API deployed by the operator.
|
||||
// It exposes the HTTP contract consumed by vault-plugin-secrets-bind-tsig and
|
||||
// fulfils it by managing BindTSIGKey custom resources, which the operator
|
||||
// reconciles into key material.
|
||||
package tsigapi
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
)
|
||||
|
||||
const rotationAnnotation = "bind.unkin.net/rotated-at"
|
||||
|
||||
// Server serves the TSIG key API.
|
||||
type Server struct {
|
||||
Client client.Client
|
||||
Namespace string
|
||||
Token string
|
||||
Log logr.Logger
|
||||
// WaitTimeout bounds how long a create/rotate waits for the operator to
|
||||
// reconcile the key material.
|
||||
WaitTimeout time.Duration
|
||||
// Now is injectable for tests; defaults to time.Now.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
type keyResponse struct {
|
||||
Name string `json:"name"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
Secret string `json:"secret"`
|
||||
KeyName string `json:"key_name"`
|
||||
ClusterRef string `json:"cluster_ref,omitempty"`
|
||||
}
|
||||
|
||||
type createRequest struct {
|
||||
Name string `json:"name"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
ClusterRef string `json:"cluster_ref"`
|
||||
Static bool `json:"static"`
|
||||
}
|
||||
|
||||
// Handler returns the API's HTTP handler.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
if s.Now == nil {
|
||||
s.Now = time.Now
|
||||
}
|
||||
if s.WaitTimeout == 0 {
|
||||
s.WaitTimeout = 15 * time.Second
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) })
|
||||
mux.HandleFunc("/v1/keys", s.auth(s.handleKeys))
|
||||
mux.HandleFunc("/v1/keys/", s.auth(s.handleKey))
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Token != "" {
|
||||
want := "Bearer " + s.Token
|
||||
got := r.Header.Get("Authorization")
|
||||
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
// handleKeys serves POST /v1/keys (create).
|
||||
func (s *Server) handleKeys(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var req createRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "invalid body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
http.Error(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key, err := s.createKey(r.Context(), req)
|
||||
if err != nil {
|
||||
s.fail(w, "create", req.Name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, key)
|
||||
}
|
||||
|
||||
// handleKey serves GET/DELETE /v1/keys/{name} and POST /v1/keys/{name}/rotate.
|
||||
func (s *Server) handleKey(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v1/keys/"), "/")
|
||||
rotate := strings.HasSuffix(name, "/rotate")
|
||||
name = strings.TrimSuffix(name, "/rotate")
|
||||
if name == "" {
|
||||
http.Error(w, "key name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
switch {
|
||||
case rotate && r.Method == http.MethodPost:
|
||||
key, err := s.rotateKey(r.Context(), name)
|
||||
if err != nil {
|
||||
s.fail(w, "rotate", name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, key)
|
||||
case r.Method == http.MethodGet:
|
||||
key, err := s.readKey(r.Context(), name)
|
||||
if err != nil {
|
||||
s.fail(w, "read", name, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, key)
|
||||
case r.Method == http.MethodDelete:
|
||||
if err := s.deleteKey(r.Context(), name); err != nil {
|
||||
s.fail(w, "delete", name, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) fail(w http.ResponseWriter, op, name string, err error) {
|
||||
if apierrors.IsNotFound(err) {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.Log.Error(err, "tsig api request failed", "op", op, "name", name)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func (s *Server) key(name string) types.NamespacedName {
|
||||
return types.NamespacedName{Namespace: s.Namespace, Name: name}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package tsigapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-logr/logr"
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/types"
|
||||
clientgoscheme "k8s.io/client-go/kubernetes/scheme"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
|
||||
bindv1alpha1 "git.unkin.net/unkin/bind-operator/api/v1alpha1"
|
||||
)
|
||||
|
||||
const testNS = "bind-system"
|
||||
|
||||
// fakeOperator simulates the BindTSIGKey controller: whenever a key exists
|
||||
// without its material Secret, it creates one. It stops when ctx is cancelled.
|
||||
func fakeOperator(ctx context.Context, c client.Client) {
|
||||
seq := 0
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
var keys bindv1alpha1.BindTSIGKeyList
|
||||
if err := c.List(ctx, &keys); err != nil {
|
||||
continue
|
||||
}
|
||||
for i := range keys.Items {
|
||||
k := &keys.Items[i]
|
||||
secretName := k.Name + "-tsig"
|
||||
var existing corev1.Secret
|
||||
if err := c.Get(ctx, client.ObjectKey{Namespace: k.Namespace, Name: secretName}, &existing); err == nil {
|
||||
continue
|
||||
}
|
||||
seq++
|
||||
material := "secret-material-" + itoa(seq)
|
||||
_ = c.Create(ctx, &corev1.Secret{
|
||||
// Fake client does not assign UIDs; set one so rotation
|
||||
// (which detects a Secret with a new UID) is observable.
|
||||
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: k.Namespace, UID: types.UID("uid-" + itoa(seq))},
|
||||
Data: map[string][]byte{
|
||||
"algorithm": []byte(k.Spec.Algorithm),
|
||||
"keyName": []byte(k.Name),
|
||||
"secret": []byte(material),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func newTestServer(t *testing.T) (*Server, context.CancelFunc) {
|
||||
t.Helper()
|
||||
scheme := runtime.NewScheme()
|
||||
if err := clientgoscheme.AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("clientgo scheme: %v", err)
|
||||
}
|
||||
if err := bindv1alpha1.AddToScheme(scheme); err != nil {
|
||||
t.Fatalf("bind scheme: %v", err)
|
||||
}
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
go fakeOperator(ctx, c)
|
||||
|
||||
return &Server{
|
||||
Client: c,
|
||||
Namespace: testNS,
|
||||
Token: "s3cr3t",
|
||||
Log: logr.Discard(),
|
||||
WaitTimeout: 3 * time.Second,
|
||||
}, cancel
|
||||
}
|
||||
|
||||
func TestHealthzAndAuth(t *testing.T) {
|
||||
srv, cancel := newTestServer(t)
|
||||
defer cancel()
|
||||
h := srv.Handler()
|
||||
|
||||
// healthz needs no auth.
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("healthz: want 200, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// missing token is rejected.
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, httptest.NewRequest(http.MethodPost, "/v1/keys", strings.NewReader(`{"name":"x"}`)))
|
||||
if rr.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("no-auth: want 401, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyLifecycle(t *testing.T) {
|
||||
srv, cancel := newTestServer(t)
|
||||
defer cancel()
|
||||
h := srv.Handler()
|
||||
|
||||
// create
|
||||
created := do(t, h, http.MethodPost, "/v1/keys", `{"name":"host-a","cluster_ref":"bind-authoritative"}`, http.StatusOK)
|
||||
if created.Secret == "" {
|
||||
t.Fatalf("create returned empty secret")
|
||||
}
|
||||
if created.ClusterRef != "bind-authoritative" {
|
||||
t.Fatalf("cluster_ref not propagated: %q", created.ClusterRef)
|
||||
}
|
||||
|
||||
// read returns the same material
|
||||
got := do(t, h, http.MethodGet, "/v1/keys/host-a", "", http.StatusOK)
|
||||
if got.Secret != created.Secret {
|
||||
t.Fatalf("read secret %q != created %q", got.Secret, created.Secret)
|
||||
}
|
||||
|
||||
// rotate returns new material
|
||||
rotated := do(t, h, http.MethodPost, "/v1/keys/host-a/rotate", "", http.StatusOK)
|
||||
if rotated.Secret == created.Secret {
|
||||
t.Fatalf("rotate did not change secret")
|
||||
}
|
||||
|
||||
// delete
|
||||
rr := httptest.NewRecorder()
|
||||
req := authed(http.MethodDelete, "/v1/keys/host-a", "")
|
||||
h.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: want 204, got %d", rr.Code)
|
||||
}
|
||||
|
||||
// read after delete → 404
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, authed(http.MethodGet, "/v1/keys/host-a", ""))
|
||||
if rr.Code != http.StatusNotFound {
|
||||
t.Fatalf("read-after-delete: want 404, got %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func authed(method, path, body string) *http.Request {
|
||||
var r *http.Request
|
||||
if body == "" {
|
||||
r = httptest.NewRequest(method, path, nil)
|
||||
} else {
|
||||
r = httptest.NewRequest(method, path, strings.NewReader(body))
|
||||
}
|
||||
r.Header.Set("Authorization", "Bearer s3cr3t")
|
||||
return r
|
||||
}
|
||||
|
||||
func do(t *testing.T, h http.Handler, method, path, body string, wantCode int) keyResponse {
|
||||
t.Helper()
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, authed(method, path, body))
|
||||
if rr.Code != wantCode {
|
||||
t.Fatalf("%s %s: want %d, got %d (%s)", method, path, wantCode, rr.Code, rr.Body.String())
|
||||
}
|
||||
var resp keyResponse
|
||||
if rr.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
}
|
||||
return resp
|
||||
}
|
||||
Reference in New Issue
Block a user