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