1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
package clusterprovider
import (
"context"
"crypto/rand"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/johnandersen777/socialweb-computer-kcp/api/v1alpha1"
"github.com/johnandersen777/socialweb-computer-kcp/internal/clusterlink"
"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/opt"
"github.com/johnandersen777/socialweb-computer-kcp/internal/kit/supervise"
"github.com/johnandersen777/socialweb-computer-kcp/internal/procgroup"
"github.com/johnandersen777/socialweb-computer-kcp/internal/vault/openbao"
)
const linkFailureTail = 600
const DefaultLinkRetry = 2 * time.Second
var ErrNoLinkSeam = errors.New(
"clusterprovider: cluster links are on and this Stack runs no vault or no k3s. A link's iroh " +
"key lives in the cluster's own OpenBao, so with no vault there is nowhere to keep it and " +
"every restart would mint a new endpoint id; with no k3s there is no apiserver to front")
var ErrNoLinkRecorder = errors.New(
"clusterprovider: cluster links are on and nothing records them. The ClusterLink is the " +
"only place a link's ticket and did:key are published, so a listener nobody records is " +
"one no node can dial")
type LinkRecorder interface {
WriteLink(ctx context.Context, link v1alpha1.ClusterLink) error
WriteLinkStatus(ctx context.Context, cluster, name string, status v1alpha1.ClusterLinkStatus) error
}
type LinkKeys func(ctx context.Context, dataDir string, vaultPort int) (clusterlink.KeyStore, error)
type Links struct {
Dumbpipe string
Recorder LinkRecorder
Keys LinkKeys
Random io.Reader
Retry time.Duration
Log *slog.Logger
writes sync.WaitGroup
}
func (l *Links) Wait(ctx context.Context) error { return supervise.Join(ctx, l.writes.Wait) }
type linkTarget struct {
component procgroup.Component
service v1alpha1.ClusterLinkService
port int
}
func linksAfterK3s(steps []procgroup.Step, cfg StackConfig, cluster, dataDir string, apiserverPort, vaultPort int) ([]procgroup.Step, error) {
links := cfg.Links
if !cfg.Stack.Has(procgroup.ComponentOpenBao) || !cfg.Stack.Has(procgroup.ComponentK3s) || vaultPort == 0 {
return nil, fmt.Errorf("%w (stack %v, cluster %s)", ErrNoLinkSeam, cfg.Stack, cluster)
}
if links.Recorder == nil {
return nil, fmt.Errorf("%w (cluster %s)", ErrNoLinkRecorder, cluster)
}
if links.Dumbpipe == "" {
return nil, fmt.Errorf("clusterprovider: cluster links are on and no dumbpipe binary is named (cluster %s)", cluster)
}
keys := links.Keys
if keys == nil {
return nil, fmt.Errorf("clusterprovider: cluster links are on and no link key store is configured (cluster %s)", cluster)
}
random := links.Random
if random == nil {
random = rand.Reader
}
targets := []linkTarget{
{procgroup.ComponentApiserverLink, v1alpha1.ClusterLinkApiserver, apiserverPort},
{procgroup.ComponentOpenBaoLink, v1alpha1.ClusterLinkOpenBao, vaultPort},
}
for _, t := range targets {
rec := cfg.linkSlotOf(cluster, t.service)
slot := slotOr(rec.Slot)
output, err := componentLog(dataDir, t.component)
if err != nil {
return nil, err
}
if slot != ActiveSlot {
output = strings.TrimSuffix(output, ".log") + "@" + slot + ".log"
}
dumbpipe := links.Dumbpipe
if rec.Dumbpipe != "" {
dumbpipe = rec.Dumbpipe
}
run := &linkRun{
links: links,
cluster: cluster,
target: t,
envFile: linkEnvFile(dataDir, t.service),
keys: func(ctx context.Context) (clusterlink.KeyStore, error) { return keys(ctx, dataDir, vaultPort) },
random: random,
}
steps = append(steps, procgroup.Step{
Name: t.component,
Slot: linkStepSlot(slot),
Spec: procgroup.Spec{Argv: clusterlink.Argv(dumbpipe, t.port), Log: output},
Prepare: run.prepare,
Ready: run.ready,
})
}
return steps, nil
}
func linkStepSlot(slot string) string {
if slot == ActiveSlot {
return ""
}
return slot
}
func (cfg StackConfig) linkSlotOf(cluster string, service v1alpha1.ClusterLinkService) LinkRecord {
if cfg.LinkSlot != nil {
return cfg.LinkSlot(cluster, service)
}
return LinkRecord{Slot: ActiveSlot}
}
func linkEnvFile(dataDir string, service v1alpha1.ClusterLinkService) string {
return filepath.Join(dataDir, "links", string(service)+".env")
}
type linkRun struct {
links *Links
cluster string
target linkTarget
envFile string
keys func(ctx context.Context) (clusterlink.KeyStore, error)
random io.Reader
mu sync.Mutex
key clusterlink.Key
}
func (r *linkRun) name() string { return clusterlink.Name(r.cluster, r.target.service) }
func (r *linkRun) log() *slog.Logger {
if r.links.Log != nil {
return r.links.Log
}
return slog.Default()
}
func (r *linkRun) prepare(ctx context.Context, spec procgroup.Spec) (procgroup.Spec, error) {
store, err := r.keys(ctx)
if err != nil {
return spec, fmt.Errorf("clusterprovider: the %s link of %s keeps its key in the cluster vault: %w",
r.target.service, r.cluster, err)
}
key, err := clusterlink.EnsureKey(ctx, store, r.target.service, r.random)
if err != nil {
return spec, err
}
r.mu.Lock()
r.key = key
r.mu.Unlock()
if err := os.MkdirAll(filepath.Dir(r.envFile), 0o700); err != nil {
return spec, err
}
if err := os.WriteFile(r.envFile, []byte(key.Env()+"\n"), 0o600); err != nil {
return spec, fmt.Errorf("clusterprovider: the %s link of %s reads its key from %s: %w", r.target.service, r.cluster, r.envFile, err)
}
spec.EnvFiles = []string{r.envFile}
return spec, nil
}
func (r *linkRun) currentKey() clusterlink.Key {
r.mu.Lock()
defer r.mu.Unlock()
return r.key
}
func (r *linkRun) ready(ctx context.Context, p procgroup.Member) error {
ctx, cancel := context.WithTimeout(ctx, GateTimeout)
defer cancel()
key := r.currentKey()
return untilOutput(ctx, p, func(out string) (bool, error) {
ticket, ok := clusterlink.TicketIn(out)
if !ok {
return false, nil
}
status, err := clusterlink.Listening(key, ticket)
if err != nil {
return false, err
}
link := clusterlink.Object(r.cluster, r.target.service, r.target.port, status)
wctx, done := context.WithTimeout(context.WithoutCancel(ctx), time.Minute)
r.links.writes.Go(func() {
defer done()
r.retry(wctx, "Listening", func() error { return r.links.Recorder.WriteLink(wctx, link) })
})
return true, nil
}, func() error {
return &errNotReady{
what: "the " + string(r.target.service) + " link of " + r.cluster + ". dumbpipe prints " +
"its ticket once its endpoint is bound, after one try at its home relay",
last: tail(p.Output()),
}
})
}
func (r *linkRun) retry(ctx context.Context, phase string, write func() error) {
wait := r.links.Retry
wait = opt.Positive(wait, DefaultLinkRetry)
for {
err := write()
if err == nil {
r.log().Info("cluster provider: cluster link recorded", "cluster", r.cluster,
"link", r.name(), "phase", phase)
return
}
r.log().Warn("cluster provider: recording the cluster link failed; retrying",
"cluster", r.cluster, "link", r.name(), "phase", phase, "error", err.Error())
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
}
}
func tail(out string) string {
out = strings.TrimSpace(out)
if len(out) > linkFailureTail {
out = out[len(out)-linkFailureTail:]
}
return out
}
type vaultLinkKeys struct {
client *openbao.Client
admin *vaultAdmin
}
func VaultLinkKeys(logins *openbao.Logins) LinkKeys {
return func(ctx context.Context, dataDir string, vaultPort int) (clusterlink.KeyStore, error) {
client, _, err := clusterVault(ctx, logins, dataDir)
if err != nil {
return nil, err
}
pool, err := vaultCA(dataDir)
if err != nil {
return nil, err
}
addr := VaultAddr(vaultPort)
return &vaultLinkKeys{client: client, admin: newVaultAdmin(addr, client, pool)}, nil
}
}
func linkDataPath(path string) string { return clusterlink.Mount + "/data/" + strings.Trim(path, "/") }
func kvUpgrading(body string) bool { return strings.Contains(strings.ToLower(body), "upgrad") }
func (s *vaultLinkKeys) Read(ctx context.Context, path string) (map[string]any, bool, error) {
var out map[string]any
var found bool
err := s.untilMounted(ctx, func() (bool, error) {
data, err := s.client.GetKV(ctx, linkDataPath(path))
var re *openbao.ResponseError
switch {
case errors.Is(err, openbao.ErrNotFound):
return true, nil
case errors.As(err, &re) && kvUpgrading(re.Body):
return false, nil
case err != nil:
return false, err
}
out, found = data, true
return true, nil
})
return out, found, err
}
func (s *vaultLinkKeys) Create(ctx context.Context, path string, data map[string]any) (bool, error) {
var created bool
err := s.untilMounted(ctx, func() (bool, error) {
status, body, err := s.admin.do(ctx, http.MethodPost, linkDataPath(path), map[string]any{
"options": map[string]any{"cas": 0},
"data": data,
})
switch {
case err != nil:
return false, err
case status >= 200 && status <= 299:
created = true
return true, nil
case kvUpgrading(string(body)):
return false, nil
case status == http.StatusBadRequest && strings.Contains(string(body), "check-and-set"):
return true, nil
}
return false, fmt.Errorf("clusterprovider: POST /v1/%s -> HTTP %d: %s", linkDataPath(path), status,
strings.TrimSpace(string(body)))
})
return created, err
}
func (s *vaultLinkKeys) untilMounted(ctx context.Context, try func() (bool, error)) error {
ctx, cancel := context.WithTimeout(ctx, GateTimeout)
defer cancel()
return poll(ctx, func(context.Context) (bool, error) { return try() }, func() error {
return fmt.Errorf("clusterprovider: the %s KV v2 mount never finished its upgrade to versioned data",
clusterlink.Mount)
})
}