Skip to content

Commit 8a543ff

Browse files
authored
feat: make connector instances replaceable (#12)
* feat: make connector instances replaceable * fix: satisfy connector e2e lint * fix: install connector instance CRD * fix: wait for connector e2e cleanup
1 parent 9f47913 commit 8a543ff

24 files changed

Lines changed: 4001 additions & 1379 deletions

PROJECT

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,15 @@ layout:
88
projectName: kodiak
99
repo: github.com/mNi-Cloud/kodiak
1010
resources:
11+
- api:
12+
crdVersion: v1
13+
namespaced: true
14+
controller: true
15+
domain: mnicloud.jp
16+
group: kodiak
17+
kind: ConnectorInstance
18+
path: github.com/mNi-Cloud/kodiak/api/v1alpha1
19+
version: v1alpha1
1120
- api:
1221
crdVersion: v1
1322
namespaced: true

README.md

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Kodiak
22

3-
Kodiak is a Kubernetes operator for stable Tailscale subnet-router workloads.
3+
Kodiak is a Kubernetes operator for logical Tailscale subnet-router services.
44
It can use either:
55

66
- an existing Tailscale-compatible control plane and auth-key Secret; or
@@ -11,16 +11,18 @@ Kodiak does not install Ionscale, a DERP server, or cert-manager.
1111

1212
## API
1313

14-
- `Connector` creates a kernel-networking `StatefulSet`. Every replica has a
15-
stable Pod name and a dedicated Kubernetes Secret containing containerboot
16-
state. Kodiak advertises routes but never approves them.
14+
- `Connector` defines a logical subnet-router service. Every replica slot is
15+
fulfilled by a replaceable `ConnectorInstance` and a direct kernel-networking
16+
Pod. Kodiak advertises routes but never approves them.
17+
- `ConnectorInstance` is an internal lifecycle record for one Pod incarnation
18+
and its external device. It does not contain tailscaled private state.
1719
- `Tailnet` manages Ionscale Tailnet policy, DNS, and feature settings.
1820
- `AuthKey` issues user/manual enrollment credentials for a managed Tailnet.
1921
Connectors do not depend on `AuthKey` resources.
2022

21-
Managed Connectors use a `tailnetRef`. Kodiak creates a short-lived,
22-
pre-authorized bootstrap credential for each replica, waits for containerboot
23-
to persist the device identity, then revokes the bootstrap credential. Route
23+
Managed Connectors use a `tailnetRef`. Kodiak creates a short-lived, ephemeral,
24+
pre-authorized bootstrap credential for each new instance, records the
25+
registered device, then revokes and deletes the bootstrap credential. Route
2426
approval belongs in the Tailnet ACL `autoApprovers` policy.
2527

2628
External Connectors use an `authKeySecretRef`. If `loginURL` is omitted, the
@@ -51,7 +53,7 @@ For Kustomize development installs:
5153

5254
```sh
5355
make install
54-
make deploy IMG=ghcr.io/mni-cloud/kodiak:0.2.0
56+
make deploy IMG=ghcr.io/mni-cloud/kodiak:0.2.1
5557
```
5658

5759
Patch `IONSCALE_API_ENDPOINT`, `IONSCALE_LOGIN_URL`, and the optional
@@ -107,9 +109,14 @@ Kodiak exposes advertised and enabled routes separately in
107109
## Security boundary
108110

109111
Kodiak never calls the Ionscale route-enable API; `autoApprovers` is the only
110-
managed route-approval authority. Connector bootstrap keys stay in
111-
controller-owned replica State Secrets and are revoked after identity is
112-
persisted.
112+
managed route-approval authority. Managed bootstrap keys are transient
113+
controller-owned Secrets and are revoked after the external device is
114+
recorded.
115+
116+
Connector Pods use the stock Tailscale image with an `emptyDir` state
117+
directory. They do not receive a service-account token or Kubernetes API
118+
credentials. A Pod replacement intentionally creates a new external device;
119+
Kodiak brings it to readiness before deleting the previous instance.
113120

114121
Ionscale must also enforce that tags requested during registration are a
115122
subset of the tags carried by the auth key. Until that control-plane check is

api/v1alpha1/connector_types.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,15 @@ import (
2323

2424
const (
2525
ConnectorConditionReady = "Ready"
26+
ConnectorConditionAvailable = "Available"
2627
ConnectorConditionWorkloadReady = "WorkloadReady"
27-
ConnectorConditionIdentityReady = "IdentityReady"
2828
ConnectorConditionControlPlaneReady = "ControlPlaneReady"
2929
ConnectorConditionRoutesReady = "RoutesReady"
3030
)
3131

32-
// ConnectorSpec describes a kernel-networking Tailscale subnet router.
32+
// ConnectorSpec describes a logical kernel-networking Tailscale subnet-router
33+
// service. Individual Tailscale devices are replaceable implementation
34+
// resources and are not stable identities.
3335
//
3436
// A managed Connector references a Kodiak Tailnet. Kodiak creates short-lived
3537
// bootstrap credentials for each replica and observes the corresponding
@@ -78,7 +80,7 @@ type ConnectorSpec struct {
7880
// +required
7981
SubnetRouter SubnetRouterSpec `json:"subnetRouter"`
8082

81-
// Workload customizes the generated StatefulSet without exposing
83+
// Workload customizes the generated Pods without exposing
8284
// Tailscale implementation flags.
8385
// +optional
8486
Workload ConnectorWorkloadSpec `json:"workload,omitempty"`
@@ -130,7 +132,7 @@ type ConnectorPodMetadata struct {
130132
Annotations map[string]string `json:"annotations,omitempty"`
131133
}
132134

133-
// ConnectorStatus reports workload, identity, and managed control-plane state.
135+
// ConnectorStatus reports aggregate workload and managed control-plane state.
134136
type ConnectorStatus struct {
135137
// ObservedGeneration is the most recent generation reconciled.
136138
// +optional
@@ -140,7 +142,8 @@ type ConnectorStatus struct {
140142
// +optional
141143
ManagedTailnetID string `json:"managedTailnetID,omitempty"`
142144

143-
// Devices reports one stable identity per desired replica.
145+
// Devices reports the active external device for each desired replica slot.
146+
// Device IDs, addresses, and hostnames can change when a Pod is replaced.
144147
// +optional
145148
// +listType=map
146149
// +listMapKey=ordinal
@@ -153,12 +156,12 @@ type ConnectorStatus struct {
153156
Conditions []metav1.Condition `json:"conditions,omitempty"`
154157
}
155158

156-
// ConnectorDeviceStatus is the observed state for one StatefulSet ordinal.
159+
// ConnectorDeviceStatus is the observed state for one replaceable replica.
157160
type ConnectorDeviceStatus struct {
158-
// Ordinal identifies the StatefulSet replica.
161+
// Ordinal identifies the logical replica slot.
159162
Ordinal int32 `json:"ordinal"`
160163

161-
// DeviceID is the stable Tailscale node ID written by containerboot.
164+
// DeviceID is the provider's current Tailscale node ID when available.
162165
// +optional
163166
DeviceID string `json:"deviceID,omitempty"`
164167

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/*
2+
Copyright 2025.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package v1alpha1
18+
19+
import (
20+
corev1 "k8s.io/api/core/v1"
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
)
23+
24+
const ConnectorInstanceConditionReady = "Ready"
25+
26+
// ConnectorInstanceSpec is an immutable snapshot of one Connector replica
27+
// incarnation. ConnectorInstance is an implementation resource owned by a
28+
// Connector, not a user-facing identity resource.
29+
//
30+
// +kubebuilder:validation:XValidation:rule="has(self.managedTailnetID) != has(self.authKeySecretRef)",message="exactly one of managedTailnetID or authKeySecretRef must be specified"
31+
type ConnectorInstanceSpec struct {
32+
// ConnectorRef identifies the owning logical Connector.
33+
// +required
34+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="connectorRef is immutable"
35+
ConnectorRef corev1.LocalObjectReference `json:"connectorRef"`
36+
37+
// Slot is the logical replica position served by this incarnation.
38+
// +kubebuilder:validation:Minimum=0
39+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="slot is immutable"
40+
Slot int32 `json:"slot"`
41+
42+
// Revision changes whenever the resolved Connector configuration changes.
43+
// +required
44+
// +kubebuilder:validation:MinLength=8
45+
// +kubebuilder:validation:MaxLength=64
46+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="revision is immutable"
47+
Revision string `json:"revision"`
48+
49+
// ManagedTailnetID selects a Kodiak-managed Ionscale Tailnet. Empty means
50+
// the control plane is externally managed.
51+
// +optional
52+
// +kubebuilder:validation:Pattern=`^[0-9]+$`
53+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="managedTailnetID is immutable"
54+
ManagedTailnetID string `json:"managedTailnetID,omitempty"`
55+
56+
// LoginURL is the Tailscale protocol endpoint.
57+
// +optional
58+
// +kubebuilder:validation:Pattern=`^https://[^[:space:]]+$`
59+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="loginURL is immutable"
60+
LoginURL string `json:"loginURL,omitempty"`
61+
62+
// AuthKeySecretRef selects an externally managed bootstrap key.
63+
// It is mutually exclusive with managedTailnetID.
64+
// +optional
65+
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="authKeySecretRef is immutable"
66+
AuthKeySecretRef *corev1.SecretKeySelector `json:"authKeySecretRef,omitempty"`
67+
68+
// Tags are the policy identity requested for the external device.
69+
// +optional
70+
// +listType=set
71+
Tags []string `json:"tags,omitempty"`
72+
73+
// AdvertiseRoutes are the exact prefixes served by this instance.
74+
// +required
75+
// +listType=set
76+
AdvertiseRoutes []string `json:"advertiseRoutes"`
77+
78+
// Image is the stock Tailscale container image.
79+
// +required
80+
Image string `json:"image"`
81+
82+
// Workload is the resolved Pod configuration.
83+
// +optional
84+
Workload ConnectorWorkloadSpec `json:"workload,omitempty"`
85+
}
86+
87+
// ConnectorInstanceStatus records the external child currently associated
88+
// with one Pod incarnation. It never contains tailscaled private state.
89+
type ConnectorInstanceStatus struct {
90+
// ObservedGeneration is the latest reconciled generation.
91+
// +optional
92+
ObservedGeneration int64 `json:"observedGeneration,omitempty"`
93+
94+
// PodName is the controller-owned Pod for this incarnation.
95+
// +optional
96+
PodName string `json:"podName,omitempty"`
97+
98+
// PodUID disambiguates a recreated Pod with the same name.
99+
// +optional
100+
PodUID string `json:"podUID,omitempty"`
101+
102+
// RequestedHostname is the unique correlation key presented to the
103+
// control plane during registration.
104+
// +optional
105+
RequestedHostname string `json:"requestedHostname,omitempty"`
106+
107+
// Device is the last external control-plane observation.
108+
// +optional
109+
Device ConnectorDeviceStatus `json:"device,omitempty"`
110+
111+
// Conditions reports provisioning and route readiness.
112+
// +optional
113+
// +listType=map
114+
// +listMapKey=type
115+
Conditions []metav1.Condition `json:"conditions,omitempty"`
116+
}
117+
118+
// +kubebuilder:object:root=true
119+
// +kubebuilder:subresource:status
120+
// +kubebuilder:resource:shortName={"kconnectorinstance"}
121+
// +kubebuilder:printcolumn:name="CONNECTOR",type="string",JSONPath=".spec.connectorRef.name"
122+
// +kubebuilder:printcolumn:name="SLOT",type="integer",JSONPath=".spec.slot"
123+
// +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status"
124+
// +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp"
125+
126+
// ConnectorInstance is an internal lifecycle record for one replaceable
127+
// Connector device and its Pod.
128+
type ConnectorInstance struct {
129+
metav1.TypeMeta `json:",inline"`
130+
metav1.ObjectMeta `json:"metadata,omitempty"`
131+
132+
Spec ConnectorInstanceSpec `json:"spec"`
133+
Status ConnectorInstanceStatus `json:"status,omitempty"`
134+
}
135+
136+
// +kubebuilder:object:root=true
137+
138+
// ConnectorInstanceList contains a list of ConnectorInstance.
139+
type ConnectorInstanceList struct {
140+
metav1.TypeMeta `json:",inline"`
141+
metav1.ListMeta `json:"metadata,omitempty"`
142+
Items []ConnectorInstance `json:"items"`
143+
}
144+
145+
func init() {
146+
SchemeBuilder.Register(&ConnectorInstance{}, &ConnectorInstanceList{})
147+
}

0 commit comments

Comments
 (0)