1package cli
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/bnema/gordon/internal/adapters/dto"
10 "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11 "github.com/bnema/gordon/internal/app"
12 "github.com/bnema/gordon/internal/boundaries/in"
13 "github.com/bnema/gordon/internal/domain"
14 "github.com/bnema/gordon/internal/usecase/config"
15)
16
17type localControlPlane struct {
18 configSvc in.ConfigService
19 secretSvc in.SecretService
20 containerSvc in.ContainerService
21 backupSvc in.BackupService
22 registrySvc in.RegistryService
23 deployCoord in.DeployCoordinator
24 healthSvc in.HealthService
25 logSvc in.LogService
26 volumeSvc in.VolumeService
27}
28
29func NewLocalControlPlane(kernel *app.Kernel) ControlPlane {
30 if kernel == nil {
31 return &localControlPlane{}
32 }
33
34 registrySvc := kernel.Registry()
35 var deployCoord in.DeployCoordinator
36 if registrySvc != nil {
37 if coordinator, ok := any(registrySvc).(in.DeployCoordinator); ok {
38 deployCoord = coordinator
39 }
40 }
41
42 return &localControlPlane{
43 configSvc: kernel.Config(),
44 secretSvc: kernel.Secrets(),
45 containerSvc: kernel.Container(),
46 backupSvc: kernel.Backup(),
47 registrySvc: registrySvc,
48 deployCoord: deployCoord,
49 healthSvc: kernel.Health(),
50 logSvc: kernel.Logs(),
51 volumeSvc: kernel.Volumes(),
52 }
53}
54
55func (l *localControlPlane) ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) {
56 if l.containerSvc != nil {
57 return toRemoteRouteInfos(l.containerSvc.ListRoutesWithDetails(ctx)), nil
58 }
59
60 if l.configSvc == nil {
61 return nil, fmt.Errorf("local config service unavailable")
62 }
63
64 routes := l.configSvc.GetRoutes(ctx)
65 infos := make([]remote.RouteInfo, 0, len(routes))
66 for _, route := range routes {
67 infos = append(infos, remote.RouteInfo{Domain: route.Domain, Image: route.Image})
68 }
69 return infos, nil
70}
71
72func (l *localControlPlane) GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) {
73 if l.healthSvc == nil {
74 return map[string]*remote.RouteHealth{}, nil
75 }
76
77 health := l.healthSvc.CheckAllRoutes(ctx)
78 result := make(map[string]*remote.RouteHealth, len(health))
79 for domainName, h := range health {
80 if h == nil {
81 continue
82 }
83 result[domainName] = &remote.RouteHealth{
84 ContainerStatus: h.ContainerStatus,
85 HTTPStatus: h.HTTPStatus,
86 ResponseTimeMs: h.ResponseTimeMs,
87 Healthy: h.Healthy,
88 Error: h.Error,
89 }
90 }
91 return result, nil
92}
93
94func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) {
95 if l.configSvc == nil {
96 return nil, fmt.Errorf("local config service unavailable")
97 }
98 return l.configSvc.GetRoute(ctx, routeDomain)
99}
100
101func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) {
102 if l.configSvc == nil {
103 return nil, fmt.Errorf("local config service unavailable")
104 }
105 return l.configSvc.FindRoutesByImage(ctx, imageName), nil
106}
107
108func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error {
109 if l.configSvc == nil {
110 return fmt.Errorf("local config service unavailable")
111 }
112 return l.configSvc.AddRoute(ctx, route)
113}
114
115func (l *localControlPlane) UpdateRoute(ctx context.Context, route domain.Route) error {
116 if l.configSvc == nil {
117 return fmt.Errorf("local config service unavailable")
118 }
119 return l.configSvc.UpdateRoute(ctx, route)
120}
121
122func (l *localControlPlane) RemoveRoute(ctx context.Context, routeDomain string) error {
123 if l.configSvc == nil {
124 return fmt.Errorf("local config service unavailable")
125 }
126 return l.configSvc.RemoveRoute(ctx, routeDomain)
127}
128
129func (l *localControlPlane) Bootstrap(ctx context.Context, req dto.BootstrapRequest) (*dto.BootstrapResponse, error) {
130 registryDomain := ""
131 if l.configSvc != nil {
132 registryDomain = l.configSvc.GetRegistryDomain()
133 }
134 normalizedImage, err := config.NormalizeBootstrapImage(req.Image, registryDomain)
135 if err != nil {
136 return nil, fmt.Errorf("invalid image: %w", err)
137 }
138
139 resp := &dto.BootstrapResponse{
140 Domain: req.Domain,
141 Image: normalizedImage,
142 Next: fmt.Sprintf("push %s to trigger deployment", normalizedImage),
143 }
144
145 if l.configSvc == nil {
146 return resp, fmt.Errorf("local config service unavailable")
147 }
148 needsSecrets := len(req.Env) > 0 || len(req.AttachmentEnv) > 0
149 if needsSecrets && l.secretSvc == nil {
150 return resp, fmt.Errorf("local secret service unavailable")
151 }
152
153 addStep := func(name, status string) {
154 resp.Steps = append(resp.Steps, dto.BootstrapStep{Name: name, Status: status})
155 }
156
157 err = l.configSvc.AddRoute(ctx, domain.Route{Domain: req.Domain, Image: normalizedImage})
158 switch err {
159 case nil:
160 addStep("route", "configured")
161 default:
162 addStep("route", "failed")
163 return resp, err
164 }
165
166 if err := l.bootstrapAttachments(ctx, req, addStep); err != nil {
167 return resp, err
168 }
169
170 if err := l.bootstrapSecrets(ctx, req, addStep); err != nil {
171 return resp, err
172 }
173
174 if err := l.bootstrapAttachmentSecrets(ctx, req, addStep); err != nil {
175 return resp, err
176 }
177
178 return resp, nil
179}
180
181func (l *localControlPlane) bootstrapAttachments(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
182 for _, attachment := range req.Attachments {
183 err := l.configSvc.AddAttachment(ctx, req.Domain, attachment)
184 if err == nil {
185 addStep("attachment:"+attachment, "created")
186 continue
187 }
188 if errors.Is(err, domain.ErrAttachmentExists) {
189 addStep("attachment:"+attachment, "noop")
190 continue
191 }
192 addStep("attachment:"+attachment, "failed")
193 return err
194 }
195
196 return nil
197}
198
199func (l *localControlPlane) bootstrapSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
200 if len(req.Env) == 0 {
201 return nil
202 }
203
204 if err := l.secretSvc.Set(ctx, req.Domain, req.Env); err != nil {
205 addStep("env", "failed")
206 return err
207 }
208
209 addStep("env", "updated")
210 return nil
211}
212
213func (l *localControlPlane) bootstrapAttachmentSecrets(ctx context.Context, req dto.BootstrapRequest, addStep func(name, status string)) error {
214 for service, env := range req.AttachmentEnv {
215 if err := l.secretSvc.SetAttachment(ctx, req.Domain, service, env); err != nil {
216 addStep("attachment_env:"+service, "failed")
217 return err
218 }
219 addStep("attachment_env:"+service, "updated")
220 }
221
222 return nil
223}
224
225func (l *localControlPlane) ListSecretsWithAttachments(ctx context.Context, secretDomain string) (*remote.SecretsListResult, error) {
226 if l.secretSvc == nil {
227 return nil, fmt.Errorf("local secret service unavailable")
228 }
229
230 keys, attachments, err := l.secretSvc.ListKeysWithAttachments(ctx, secretDomain)
231 if err != nil {
232 return nil, err
233 }
234
235 result := &remote.SecretsListResult{
236 Domain: secretDomain,
237 Keys: keys,
238 }
239 for _, att := range attachments {
240 result.Attachments = append(result.Attachments, remote.AttachmentSecrets{
241 Service: att.Service,
242 Keys: att.Keys,
243 })
244 }
245
246 return result, nil
247}
248
249func (l *localControlPlane) SetSecrets(ctx context.Context, secretDomain string, secrets map[string]string) error {
250 if l.secretSvc == nil {
251 return fmt.Errorf("local secret service unavailable")
252 }
253 return l.secretSvc.Set(ctx, secretDomain, secrets)
254}
255
256func (l *localControlPlane) DeleteSecret(ctx context.Context, secretDomain, key string) error {
257 if l.secretSvc == nil {
258 return fmt.Errorf("local secret service unavailable")
259 }
260 return l.secretSvc.Delete(ctx, secretDomain, key)
261}
262
263func (l *localControlPlane) SetAttachmentSecrets(ctx context.Context, domainName, service string, secrets map[string]string) error {
264 if l.secretSvc == nil {
265 return fmt.Errorf("local secret service unavailable")
266 }
267 return l.secretSvc.SetAttachment(ctx, domainName, service, secrets)
268}
269
270func (l *localControlPlane) DeleteAttachmentSecret(ctx context.Context, domainName, service, key string) error {
271 if l.secretSvc == nil {
272 return fmt.Errorf("local secret service unavailable")
273 }
274 return l.secretSvc.DeleteAttachment(ctx, domainName, service, key)
275}
276
277func (l *localControlPlane) GetAllAttachmentsConfig(ctx context.Context) (map[string][]string, error) {
278 if l.configSvc == nil {
279 return nil, fmt.Errorf("local config service unavailable")
280 }
281 return l.configSvc.GetAllAttachments(ctx), nil
282}
283
284func (l *localControlPlane) GetAttachmentsConfig(ctx context.Context, domainOrGroup string) ([]string, error) {
285 if l.configSvc == nil {
286 return nil, fmt.Errorf("local config service unavailable")
287 }
288 return l.configSvc.GetAttachmentsFor(ctx, domainOrGroup)
289}
290
291func (l *localControlPlane) FindAttachmentTargetsByImage(ctx context.Context, imageName string) ([]string, error) {
292 if l.configSvc == nil {
293 return nil, fmt.Errorf("local config service unavailable")
294 }
295 return l.configSvc.FindAttachmentTargetsByImage(ctx, imageName), nil
296}
297
298func (l *localControlPlane) AddAttachment(ctx context.Context, domainOrGroup, image string) error {
299 if l.configSvc == nil {
300 return fmt.Errorf("local config service unavailable")
301 }
302 return l.configSvc.AddAttachment(ctx, domainOrGroup, image)
303}
304
305func (l *localControlPlane) RemoveAttachment(ctx context.Context, domainOrGroup, image string) error {
306 if l.configSvc == nil {
307 return fmt.Errorf("local config service unavailable")
308 }
309 return l.configSvc.RemoveAttachment(ctx, domainOrGroup, image)
310}
311
312func (l *localControlPlane) GetAutoRouteAllowedDomains(ctx context.Context) ([]string, error) {
313 if l.configSvc == nil {
314 return nil, fmt.Errorf("local config service unavailable")
315 }
316 return l.configSvc.GetAutoRouteAllowedDomains(ctx)
317}
318
319func (l *localControlPlane) AddAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
320 if l.configSvc == nil {
321 return fmt.Errorf("local config service unavailable")
322 }
323 return l.configSvc.AddAutoRouteAllowedDomain(ctx, pattern)
324}
325
326func (l *localControlPlane) RemoveAutoRouteAllowedDomain(ctx context.Context, pattern string) error {
327 if l.configSvc == nil {
328 return fmt.Errorf("local config service unavailable")
329 }
330 return l.configSvc.RemoveAutoRouteAllowedDomain(ctx, pattern)
331}
332
333func (l *localControlPlane) GetStatus(ctx context.Context) (*remote.Status, error) {
334 if l.configSvc == nil {
335 return nil, fmt.Errorf("local config service unavailable")
336 }
337
338 status := &remote.Status{
339 Routes: len(l.configSvc.GetRoutes(ctx)),
340 RegistryDomain: l.configSvc.GetRegistryDomain(),
341 RegistryPort: l.configSvc.GetRegistryPort(),
342 ServerPort: l.configSvc.GetServerPort(),
343 AutoRoute: l.configSvc.IsAutoRouteEnabled(),
344 NetworkIsolation: l.configSvc.IsNetworkIsolationEnabled(),
345 ContainerStatus: map[string]string{},
346 }
347
348 if l.containerSvc != nil {
349 for domainName, container := range l.containerSvc.List(ctx) {
350 if container == nil {
351 continue
352 }
353 status.ContainerStatus[domainName] = container.Status
354 }
355 }
356
357 return status, nil
358}
359
360func (l *localControlPlane) Reload(_ context.Context) error {
361 return app.SendReloadSignal()
362}
363
364func (l *localControlPlane) ListNetworks(ctx context.Context) ([]*domain.NetworkInfo, error) {
365 if l.containerSvc == nil {
366 return nil, fmt.Errorf("container service unavailable")
367 }
368 return l.containerSvc.ListNetworks(ctx)
369}
370
371func (l *localControlPlane) GetConfig(ctx context.Context) (*remote.Config, error) {
372 if l.configSvc == nil {
373 return nil, fmt.Errorf("config service unavailable")
374 }
375 cfg := &remote.Config{
376 Routes: l.configSvc.GetRoutes(ctx),
377 ExternalRoutes: l.configSvc.GetExternalRoutes(),
378 }
379 cfg.Server.Port = l.configSvc.GetServerPort()
380 cfg.Server.RegistryPort = l.configSvc.GetRegistryPort()
381 cfg.Server.RegistryDomain = l.configSvc.GetRegistryDomain()
382 cfg.Server.DataDir = l.configSvc.GetDataDir()
383 cfg.AutoRoute.Enabled = l.configSvc.IsAutoRouteEnabled()
384 cfg.NetworkIsolation.Enabled = l.configSvc.IsNetworkIsolationEnabled()
385 cfg.NetworkIsolation.Prefix = l.configSvc.GetNetworkPrefix()
386 return cfg, nil
387}
388
389func (l *localControlPlane) DeployIntent(_ context.Context, imageName string) error {
390 if l.deployCoord != nil {
391 l.deployCoord.SuppressDeployEvent(imageName)
392 }
393 return nil
394}
395
396func (l *localControlPlane) Deploy(ctx context.Context, deployDomain string) (*remote.DeployResult, error) {
397 if l.containerSvc != nil && l.configSvc != nil {
398 route, err := l.configSvc.GetRoute(ctx, deployDomain)
399 if err != nil {
400 return nil, err
401 }
402 container, err := l.containerSvc.Deploy(ctx, *route)
403 if err != nil {
404 return nil, err
405 }
406 result := &remote.DeployResult{Status: "deployed", Domain: deployDomain}
407 if container != nil {
408 result.ContainerID = container.ID
409 }
410 return result, nil
411 }
412
413 domainName, err := app.SendDeploySignal(deployDomain)
414 if err != nil {
415 return nil, err
416 }
417 return &remote.DeployResult{Status: "queued", Domain: domainName}, nil
418}
419
420func (l *localControlPlane) Restart(ctx context.Context, restartDomain string, withAttachments bool) (*remote.RestartResult, error) {
421 if l.containerSvc == nil {
422 if withAttachments {
423 return nil, fmt.Errorf("local restart with attachments requires active local container service")
424 }
425 domainName, err := app.SendDeploySignal(restartDomain)
426 if err != nil {
427 return nil, err
428 }
429 return &remote.RestartResult{Status: "queued", Domain: domainName}, nil
430 }
431
432 if err := l.containerSvc.SyncContainers(ctx); err != nil {
433 return nil, err
434 }
435 if err := l.containerSvc.Restart(ctx, restartDomain, withAttachments); err != nil {
436 return nil, err
437 }
438
439 return &remote.RestartResult{Status: "restarted", Domain: restartDomain}, nil
440}
441
442func (l *localControlPlane) ListTags(ctx context.Context, repository string) ([]string, error) {
443 if l.registrySvc == nil {
444 return nil, fmt.Errorf("local registry service unavailable")
445 }
446 return l.registrySvc.ListTags(ctx, repository)
447}
448
449func (l *localControlPlane) ListBackups(ctx context.Context, backupDomain string) ([]dto.BackupJob, error) {
450 if l.backupSvc == nil {
451 return nil, fmt.Errorf("local backup service unavailable")
452 }
453 jobs, err := l.backupSvc.ListBackups(ctx, backupDomain)
454 if err != nil {
455 return nil, err
456 }
457 return toDTOBackupJobs(jobs), nil
458}
459
460func (l *localControlPlane) BackupStatus(ctx context.Context) ([]dto.BackupJob, error) {
461 if l.backupSvc == nil {
462 return nil, fmt.Errorf("local backup service unavailable")
463 }
464 jobs, err := l.backupSvc.Status(ctx)
465 if err != nil {
466 return nil, err
467 }
468 return toDTOBackupJobs(jobs), nil
469}
470
471func (l *localControlPlane) RunBackup(ctx context.Context, backupDomain, dbName string) (*dto.BackupRunResponse, error) {
472 if l.backupSvc == nil {
473 return nil, fmt.Errorf("local backup service unavailable")
474 }
475 result, err := l.backupSvc.RunBackup(ctx, backupDomain, dbName)
476 if err != nil {
477 return nil, err
478 }
479 if result == nil {
480 return &dto.BackupRunResponse{Status: "ok"}, nil
481 }
482 job := toDTOBackupJob(result.Job)
483 return &dto.BackupRunResponse{Status: "ok", Backup: &job}, nil
484}
485
486func (l *localControlPlane) DetectDatabases(ctx context.Context, backupDomain string) ([]dto.DatabaseInfo, error) {
487 if l.backupSvc == nil {
488 return nil, fmt.Errorf("local backup service unavailable")
489 }
490 dbs, err := l.backupSvc.DetectDatabases(ctx, backupDomain)
491 if err != nil {
492 return nil, err
493 }
494 out := make([]dto.DatabaseInfo, 0, len(dbs))
495 for _, db := range dbs {
496 out = append(out, dto.DatabaseInfo{
497 Type: string(db.Type),
498 Name: db.Name,
499 Version: db.Version,
500 Host: db.Host,
501 Port: db.Port,
502 ContainerID: db.ContainerID,
503 ImageName: db.ImageName,
504 })
505 }
506 return out, nil
507}
508
509func (l *localControlPlane) GetProcessLogs(ctx context.Context, lines int) ([]string, error) {
510 if l.logSvc == nil {
511 return nil, fmt.Errorf("local log service unavailable")
512 }
513 return l.logSvc.GetProcessLogs(ctx, lines)
514}
515
516func (l *localControlPlane) GetContainerLogs(ctx context.Context, logDomain string, lines int) ([]string, error) {
517 if l.logSvc == nil {
518 return nil, fmt.Errorf("local log service unavailable")
519 }
520 return l.logSvc.GetContainerLogs(ctx, logDomain, lines)
521}
522
523func (l *localControlPlane) StreamProcessLogs(ctx context.Context, lines int) (<-chan string, error) {
524 if l.logSvc == nil {
525 return nil, fmt.Errorf("local log service unavailable")
526 }
527 return l.logSvc.FollowProcessLogs(ctx, lines)
528}
529
530func (l *localControlPlane) StreamContainerLogs(ctx context.Context, logDomain string, lines int) (<-chan string, error) {
531 if l.logSvc == nil {
532 return nil, fmt.Errorf("local log service unavailable")
533 }
534 return l.logSvc.FollowContainerLogs(ctx, logDomain, lines)
535}
536
537func (l *localControlPlane) ListVolumes(ctx context.Context) ([]dto.Volume, error) {
538 if l.volumeSvc == nil {
539 return nil, fmt.Errorf("volume service unavailable")
540 }
541 vols, err := l.volumeSvc.ListVolumes(ctx)
542 if err != nil {
543 return nil, err
544 }
545
546 result := make([]dto.Volume, len(vols))
547 for i, v := range vols {
548 result[i] = dto.Volume{
549 Name: v.Name,
550 Driver: v.Driver,
551 MountPoint: v.MountPoint,
552 Size: v.Size,
553 CreatedAt: v.CreatedAt,
554 InUse: v.InUse,
555 Containers: v.Containers,
556 Labels: v.Labels,
557 }
558 }
559 return result, nil
560}
561
562func (l *localControlPlane) PruneVolumes(ctx context.Context, req dto.VolumePruneRequest) (*dto.VolumePruneResponse, error) {
563 if l.volumeSvc == nil {
564 return nil, fmt.Errorf("volume service unavailable")
565 }
566 report, removed, err := l.volumeSvc.PruneVolumes(ctx, req.DryRun)
567 if err != nil {
568 return nil, err
569 }
570
571 vols := make([]dto.Volume, len(removed))
572 for i, v := range removed {
573 vols[i] = dto.Volume{
574 Name: v.Name,
575 Size: v.Size,
576 }
577 }
578
579 return &dto.VolumePruneResponse{
580 VolumesRemoved: report.VolumesRemoved,
581 SpaceReclaimed: report.SpaceReclaimed,
582 Volumes: vols,
583 }, nil
584}
585
586func toRemoteRouteInfos(routes []domain.RouteInfo) []remote.RouteInfo {
587 out := make([]remote.RouteInfo, 0, len(routes))
588 for _, route := range routes {
589 attachments := make([]dto.Attachment, 0, len(route.Attachments))
590 for _, attachment := range route.Attachments {
591 attachments = append(attachments, dto.Attachment{
592 Name: attachment.Name,
593 Image: attachment.Image,
594 ContainerID: attachment.ContainerID,
595 Status: attachment.Status,
596 Network: attachment.Network,
597 })
598 }
599
600 out = append(out, remote.RouteInfo{
601 Domain: route.Domain,
602 Image: route.Image,
603 ContainerID: route.ContainerID,
604 ContainerStatus: route.ContainerStatus,
605 Network: route.Network,
606 Attachments: attachments,
607 })
608 }
609 return out
610}
611
612func toDTOBackupJobs(jobs []domain.BackupJob) []dto.BackupJob {
613 out := make([]dto.BackupJob, 0, len(jobs))
614 for _, job := range jobs {
615 out = append(out, toDTOBackupJob(job))
616 }
617 return out
618}
619
620func toDTOBackupJob(job domain.BackupJob) dto.BackupJob {
621 var startedAt *time.Time
622 if !job.StartedAt.IsZero() {
623 t := job.StartedAt
624 startedAt = &t
625 }
626 var completedAt *time.Time
627 if !job.CompletedAt.IsZero() {
628 t := job.CompletedAt
629 completedAt = &t
630 }
631
632 return dto.BackupJob{
633 ID: job.ID,
634 Domain: job.Domain,
635 DBName: job.DBName,
636 Schedule: string(job.Schedule),
637 Type: string(job.Type),
638 Status: string(job.Status),
639 StartedAt: startedAt,
640 CompletedAt: completedAt,
641 SizeBytes: job.SizeBytes,
642 Error: job.Error,
643 }
644}