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