diff --git a/cmd/serve/front/src/assets/styles.css b/cmd/serve/front/src/assets/styles.css index e33b23b..dade858 100644 --- a/cmd/serve/front/src/assets/styles.css +++ b/cmd/serve/front/src/assets/styles.css @@ -88,7 +88,7 @@ abbr { cursor: help; } -/** Apply global styles to make things simplier */ +/** Apply global styles to make things simpler */ *:disabled, *[aria-disabled='true'] { pointer-events: none; @@ -136,7 +136,7 @@ table tbody tr { display: block; } -table tbody tr+tr { +table tbody tr + tr { border-block-start: 1px solid var(--co-divider-4); margin-block-start: var(--sp-2); padding-block-start: var(--sp-2); @@ -151,7 +151,7 @@ table tbody tr+tr { display: table-row; } - table tbody tr+tr { + table tbody tr + tr { margin: 0; padding: 0; } @@ -164,4 +164,4 @@ table tbody tr+tr { table tbody td::before { display: none; } -} \ No newline at end of file +} diff --git a/internal/deployment/app/get_deployment/get_deployment.go b/internal/deployment/app/get_deployment/get_deployment.go index 7a515e0..1ea1cf1 100644 --- a/internal/deployment/app/get_deployment/get_deployment.go +++ b/internal/deployment/app/get_deployment/get_deployment.go @@ -1,9 +1,12 @@ package get_deployment import ( + "strconv" + "strings" "time" "github.com/YuukanOO/seelf/internal/deployment/app" + "github.com/YuukanOO/seelf/internal/deployment/domain" "github.com/YuukanOO/seelf/pkg/bus" "github.com/YuukanOO/seelf/pkg/monad" "github.com/YuukanOO/seelf/pkg/storage" @@ -31,9 +34,11 @@ type ( RequestedBy app.UserSummary `json:"requested_by"` } + // This summary is specific in the sense that it represents a target which may + // have been deleted since hence the optional fields. TargetSummary struct { ID string `json:"id"` - Name monad.Maybe[string] `json:"name"` // Since the target could have been deleted, the name is nullable here. + Name monad.Maybe[string] `json:"name"` Url monad.Maybe[string] `json:"url"` Status monad.Maybe[uint8] `json:"status"` Entrypoints monad.Maybe[Entrypoints] `json:"-"` @@ -88,3 +93,66 @@ func (s *Services) Scan(value any) error { func (e *Entrypoints) Scan(value any) error { return storage.ScanJSON(value, e) } + +// Since the target domain is dynamic, compute exposed service urls based on the resolved +// target url and entrypoints if available. +// +// This method should be called after the deployment has been loaded. +func (d *Deployment) ResolveServicesUrls() { + services, hasServices := d.State.Services.TryGet() + url, hasUrl := d.Target.Url.TryGet() + entrypoints, hasEntrypoints := d.Target.Entrypoints.TryGet() + + // Target not found, could not populate services urls + if !hasUrl || !hasServices || !hasEntrypoints { + return + } + + idx := strings.Index(url, "://") + targetScheme, targetHost := url[:idx+3], url[idx+3:] + + for i, service := range services { + // Compatibility with old deployments + if service.Url.HasValue() || service.Subdomain.HasValue() { + compatEntrypoint := Entrypoint{ + Name: "default", + Router: string(domain.RouterHttp), + Port: 80, + Subdomain: service.Subdomain, // (> 2.0.0 - < 2.2.0) + Url: service.Url, // (< 2.0.0) + } + + if subdomain, isSet := compatEntrypoint.Subdomain.TryGet(); !service.Url.HasValue() && isSet { + compatEntrypoint.Url.Set(targetScheme + subdomain + "." + targetHost) + } + + services[i].Entrypoints = append(service.Entrypoints, compatEntrypoint) + continue + } + + for j, entrypoint := range service.Entrypoints { + host := targetHost + + if subdomain, isSet := entrypoint.Subdomain.TryGet(); isSet { + host = subdomain + "." + targetHost + } + + if !entrypoint.IsCustom { + entrypoint.Url.Set(targetScheme + host) + services[i].Entrypoints[j] = entrypoint + continue + } + + publishedPort, isAssigned := entrypoints[d.AppID][d.Environment][entrypoint.Name].TryGet() + + if !isAssigned { + continue + } + + entrypoint.PublishedPort.Set(publishedPort) + entrypoint.Url.Set(entrypoint.Router + "://" + host + ":" + strconv.FormatUint(uint64(publishedPort), 10)) + + services[i].Entrypoints[j] = entrypoint + } + } +} diff --git a/internal/deployment/app/get_deployment/get_deployment_test.go b/internal/deployment/app/get_deployment/get_deployment_test.go new file mode 100644 index 0000000..871ac98 --- /dev/null +++ b/internal/deployment/app/get_deployment/get_deployment_test.go @@ -0,0 +1,282 @@ +package get_deployment_test + +import ( + "testing" + + "github.com/YuukanOO/seelf/internal/deployment/app/get_deployment" + "github.com/YuukanOO/seelf/pkg/monad" + "github.com/YuukanOO/seelf/pkg/testutil" +) + +func Test_Deployment(t *testing.T) { + t.Run("should skip the url resolve if the target has been deleted", func(t *testing.T) { + d := get_deployment.Deployment{ + Target: get_deployment.TargetSummary{ + ID: "target-id", + }, + State: get_deployment.State{ + Services: monad.Value(get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "http1", + Router: "http", + IsCustom: false, + Subdomain: monad.Value("app"), + Port: 8081, + }, + { + Name: "http2", + Router: "http", + IsCustom: true, + Subdomain: monad.Value("app"), + Port: 8081, + }, + }, + }, + { + Name: "db", + Image: "db-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "tcp1", + Router: "tcp", + IsCustom: true, + Port: 5432, + }, + }, + }, + }), + }, + } + + d.ResolveServicesUrls() + + testutil.DeepEquals(t, get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "http1", + Router: "http", + IsCustom: false, + Subdomain: monad.Value("app"), + Port: 8081, + }, + { + Name: "http2", + Router: "http", + IsCustom: true, + Subdomain: monad.Value("app"), + Port: 8081, + }, + }, + }, + { + Name: "db", + Image: "db-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "tcp1", + Router: "tcp", + IsCustom: true, + Port: 5432, + }, + }, + }, + }, d.State.Services.Get(get_deployment.Services{})) + }) + + t.Run("should handle deployment data before the v2.0.0 release", func(t *testing.T) { + d := get_deployment.Deployment{ + AppID: "app-id", + Environment: "production", + Target: get_deployment.TargetSummary{ + ID: "target-id", + Url: monad.Value("https://docker.localhost"), + Entrypoints: monad.Value(get_deployment.Entrypoints{}), + }, + State: get_deployment.State{ + Services: monad.Value(get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Url: monad.Value("https://app.docker.localhost"), + }, + }), + }, + } + + d.ResolveServicesUrls() + + testutil.DeepEquals(t, get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Url: monad.Value("https://app.docker.localhost"), + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "default", + Router: "http", + IsCustom: false, + Port: 80, + Url: monad.Value("https://app.docker.localhost"), + }, + }, + }, + }, d.State.Services.Get(get_deployment.Services{})) + }) + + t.Run("should handle deployment data after the v2.0.0 and before the v2.2.0 release", func(t *testing.T) { + d := get_deployment.Deployment{ + AppID: "app-id", + Environment: "production", + Target: get_deployment.TargetSummary{ + ID: "target-id", + Url: monad.Value("https://docker.localhost"), + Entrypoints: monad.Value(get_deployment.Entrypoints{}), + }, + State: get_deployment.State{ + Services: monad.Value(get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Subdomain: monad.Value("app"), + }, + }), + }, + } + + d.ResolveServicesUrls() + + testutil.DeepEquals(t, get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Subdomain: monad.Value("app"), + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "default", + Router: "http", + IsCustom: false, + Port: 80, + Subdomain: monad.Value("app"), + Url: monad.Value("https://app.docker.localhost"), + }, + }, + }, + }, d.State.Services.Get(get_deployment.Services{})) + }) + + t.Run("should correctly handle new deployments", func(t *testing.T) { + d := get_deployment.Deployment{ + AppID: "app-id", + Environment: "production", + Target: get_deployment.TargetSummary{ + ID: "target-id", + Url: monad.Value("https://docker.localhost"), + Entrypoints: monad.Value(get_deployment.Entrypoints{ + "app-id": { + "production": { + "http2": monad.Value[uint](80810), + "tcp1": monad.Value[uint](54320), + }, + }, + }), + }, + State: get_deployment.State{ + Services: monad.Value(get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "http1", + Router: "http", + IsCustom: false, + Subdomain: monad.Value("app"), + Port: 8081, + }, + { + Name: "http2", + Router: "http", + IsCustom: true, + Subdomain: monad.Value("app"), + Port: 8081, + }, + }, + }, + { + Name: "db", + Image: "db-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "tcp1", + Router: "tcp", + IsCustom: true, + Port: 5432, + }, + { + Name: "tcp2", + Router: "tcp", + IsCustom: true, + Port: 5433, + }, + }, + }, + }), + }, + } + + d.ResolveServicesUrls() + + testutil.DeepEquals(t, get_deployment.Services{ + { + Name: "app", + Image: "app-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "http1", + Router: "http", + IsCustom: false, + Subdomain: monad.Value("app"), + Port: 8081, + Url: monad.Value("https://app.docker.localhost"), + }, + { + Name: "http2", + Router: "http", + IsCustom: true, + Subdomain: monad.Value("app"), + Port: 8081, + Url: monad.Value("http://app.docker.localhost:80810"), + PublishedPort: monad.Value[uint](80810), + }, + }, + }, + { + Name: "db", + Image: "db-image", + Entrypoints: []get_deployment.Entrypoint{ + { + Name: "tcp1", + Router: "tcp", + IsCustom: true, + Port: 5432, + Url: monad.Value("tcp://docker.localhost:54320"), + PublishedPort: monad.Value[uint](54320), + }, + { + Name: "tcp2", + Router: "tcp", + IsCustom: true, + Port: 5433, + }, + }, + }, + }, d.State.Services.Get(get_deployment.Services{})) + }) +} diff --git a/internal/deployment/domain/app.go b/internal/deployment/domain/app.go index 974f9e7..aebf6dc 100644 --- a/internal/deployment/domain/app.go +++ b/internal/deployment/domain/app.go @@ -119,7 +119,7 @@ func (AppVersionControlRemoved) Name_() string { return "deployment.event.app_ve func (AppCleanupRequested) Name_() string { return "deployment.event.app_cleanup_requested" } func (AppDeleted) Name_() string { return "deployment.event.app_deleted" } -func (e AppEnvChanged) TargetHasChanged() bool { return e.Config.Target() != e.OldConfig.Target() } +func (e AppEnvChanged) TargetHasChanged() bool { return e.Config.target != e.OldConfig.target } // Instantiates a new App. func NewApp( diff --git a/internal/deployment/domain/deployment.go b/internal/deployment/domain/deployment.go index ca3de38..d004ea7 100644 --- a/internal/deployment/domain/deployment.go +++ b/internal/deployment/domain/deployment.go @@ -79,7 +79,7 @@ func (DeploymentCreated) Name_() string { return "deployment.event.deployme func (DeploymentStateChanged) Name_() string { return "deployment.event.deployment_state_changed" } func (e DeploymentStateChanged) HasSucceeded() bool { - return e.State.Status() == DeploymentStatusSucceeded + return e.State.status == DeploymentStatusSucceeded } // Creates a new deployment for this app. This method acts as a factory for the deployment diff --git a/internal/deployment/infra/sqlite/gateway.go b/internal/deployment/infra/sqlite/gateway.go index 789d7ae..6db4bfd 100644 --- a/internal/deployment/infra/sqlite/gateway.go +++ b/internal/deployment/infra/sqlite/gateway.go @@ -2,8 +2,6 @@ package sqlite import ( "context" - "strconv" - "strings" "github.com/YuukanOO/seelf/internal/deployment/app" "github.com/YuukanOO/seelf/internal/deployment/app/get_app_deployments" @@ -235,7 +233,7 @@ func (s *gateway) GetRegistryByID(ctx context.Context, cmd get_registry.Query) ( var getDeploymentDataloader = builder.NewDataloader( func(a get_apps.App) string { return a.ID }, - func(e builder.Executor, ctx context.Context, kr builder.KeyedResult[get_apps.App]) error { + func(e builder.Executor, ctx context.Context, kr storage.KeyedResult[get_apps.App]) error { _, err := builder. Query[get_app_deployments.Deployment](` SELECT @@ -261,14 +259,14 @@ var getDeploymentDataloader = builder.NewDataloader( LEFT JOIN targets ON targets.id = deployments.config_target`). S(builder.Array("WHERE deployments.app_id IN", kr.Keys())). F("GROUP BY deployments.app_id, deployments.config_environment"). - All(e, ctx, deploymentMapper(&kr)) + All(e, ctx, deploymentMapper(kr)) return err }) var getDeploymentDetailDataloader = builder.NewDataloader( func(a get_app_detail.App) string { return a.ID }, - func(e builder.Executor, ctx context.Context, kr builder.KeyedResult[get_app_detail.App]) error { + func(e builder.Executor, ctx context.Context, kr storage.KeyedResult[get_app_detail.App]) error { _, err := builder. Query[get_deployment.Deployment](` SELECT @@ -296,7 +294,7 @@ var getDeploymentDetailDataloader = builder.NewDataloader( LEFT JOIN targets ON targets.id = deployments.config_target`). S(builder.Array("WHERE deployments.app_id IN", kr.Keys())). F("GROUP BY deployments.app_id, deployments.config_environment"). - All(e, ctx, deploymentDetailMapper(&kr)) + All(e, ctx, deploymentDetailMapper(kr)) return err }) @@ -382,7 +380,7 @@ func appDetailDataMapper(s storage.Scanner) (a get_app_detail.App, err error) { return a, err } -func deploymentMapper(kr *builder.KeyedResult[get_apps.App]) storage.Mapper[get_app_deployments.Deployment] { +func deploymentMapper(kr storage.KeyedResult[get_apps.App]) storage.Mapper[get_app_deployments.Deployment] { return func(scanner storage.Scanner) (d get_app_deployments.Deployment, err error) { var ( maxRequestedAt string @@ -437,8 +435,7 @@ func deploymentMapper(kr *builder.KeyedResult[get_apps.App]) storage.Mapper[get_ } } -func deploymentDetailMapper(kr *builder.KeyedResult[get_app_detail.App]) storage.Mapper[get_deployment.Deployment] { - +func deploymentDetailMapper(kr storage.KeyedResult[get_app_detail.App]) storage.Mapper[get_deployment.Deployment] { return func(scanner storage.Scanner) (d get_deployment.Deployment, err error) { var ( maxRequestedAt string @@ -479,7 +476,7 @@ func deploymentDetailMapper(kr *builder.KeyedResult[get_app_detail.App]) storage d.Source.Data, err = get_deployment.SourceDataTypes.From(d.Source.Discriminator, sourceData) - populateServicesUrls(&d) + d.ResolveServicesUrls() if kr != nil { kr.Update(d.AppID, func(a get_app_detail.App) get_app_detail.App { @@ -497,67 +494,6 @@ func deploymentDetailMapper(kr *builder.KeyedResult[get_app_detail.App]) storage } } -// Since the target domain is dynamic, compute exposed service urls based on the presence -// of the given current target url and resolve custom entrypoints urls too. -func populateServicesUrls(d *get_deployment.Deployment) { - services, hasServices := d.State.Services.TryGet() - url, hasUrl := d.Target.Url.TryGet() - entrypoints, hasEntrypoints := d.Target.Entrypoints.TryGet() - - // Target not found, could not populate services urls - if !hasUrl || !hasServices || !hasEntrypoints { - return - } - - idx := strings.Index(url, "://") - targetScheme, targetHost := url[:idx+3], url[idx+3:] - - for i, service := range services { - // Compatibility with old deployments - if service.Url.HasValue() || service.Subdomain.HasValue() { - compatEntrypoint := get_deployment.Entrypoint{ - Name: "default", - Router: string(domain.RouterHttp), - Port: 80, - Subdomain: service.Subdomain, // (> 2.0.0 - < 2.2.0) - Url: service.Url, // (< 2.0.0) - } - - if subdomain, isSet := compatEntrypoint.Subdomain.TryGet(); !service.Url.HasValue() && isSet { - compatEntrypoint.Url.Set(targetScheme + subdomain + "." + targetHost) - } - - services[i].Entrypoints = append(service.Entrypoints, compatEntrypoint) - continue - } - - for j, entrypoint := range service.Entrypoints { - host := targetHost - - if subdomain, isSet := entrypoint.Subdomain.TryGet(); isSet { - host = subdomain + "." + targetHost - } - - if !entrypoint.IsCustom { - entrypoint.Url.Set(targetScheme + host) - services[i].Entrypoints[j] = entrypoint - continue - } - - publishedPort, isAssigned := entrypoints[d.AppID][d.Environment][entrypoint.Name].TryGet() - - if !isAssigned { - continue - } - - entrypoint.PublishedPort.Set(publishedPort) - entrypoint.Url.Set(entrypoint.Router + "://" + host + ":" + strconv.FormatUint(uint64(publishedPort), 10)) - - services[i].Entrypoints[j] = entrypoint - } - } -} - func targetMapper(scanner storage.Scanner) (t get_target.Target, err error) { var ( providerData string diff --git a/pkg/storage/scanner.go b/pkg/storage/scanner.go index 28d9918..0672edf 100644 --- a/pkg/storage/scanner.go +++ b/pkg/storage/scanner.go @@ -27,6 +27,14 @@ type ( } Mapper[T any] func(Scanner) (T, error) // Mapper function from a simple Scanner to an object of type T + + // Represents a key indexed set of data. + KeyedResult[T any] interface { + // Retrieve the list of keys contained in this dataset. + Keys() []string + // Update the result with the given key by applying the given function if it exists. + Update(string, func(T) T) + } ) // Ease the scan of a json serialized field. diff --git a/pkg/storage/sqlite/builder/builder.go b/pkg/storage/sqlite/builder/builder.go index 317522b..1d291ef 100644 --- a/pkg/storage/sqlite/builder/builder.go +++ b/pkg/storage/sqlite/builder/builder.go @@ -164,10 +164,10 @@ func (q *queryBuilder[T]) All( results := make([]T, 0) // Instantiates needed stuff for data loaders - mappings := make([]KeysMapping, len(loaders)) + mappings := make([]keysMapping, len(loaders)) for i := range mappings { - mappings[i] = make(KeysMapping) + mappings[i] = make(keysMapping) } for rows.Next() { @@ -185,7 +185,7 @@ func (q *queryBuilder[T]) All( } if len(loaders) > 0 { - kr := KeyedResult[T]{ + kr := &keyedResult[T]{ data: results, } @@ -260,12 +260,12 @@ func (q *queryBuilder[T]) One( return result, err } - kr := KeyedResult[T]{ + kr := &keyedResult[T]{ data: []T{result}, } for _, loader := range loaders { - kr.indexByKeys = KeysMapping{ + kr.indexByKeys = keysMapping{ loader.ExtractKey(result): 0, } diff --git a/pkg/storage/sqlite/builder/loader.go b/pkg/storage/sqlite/builder/loader.go index 31152b9..0cabba5 100644 --- a/pkg/storage/sqlite/builder/loader.go +++ b/pkg/storage/sqlite/builder/loader.go @@ -1,26 +1,30 @@ package builder -import "context" +import ( + "context" + + "github.com/YuukanOO/seelf/pkg/storage" +) type ( // Represents an element which can fetch additional data from a T parent entity. // Dataloaders will be called with a list of keys to fetch as a whole to avoid // N+1 queries. Dataloader[T any] interface { - ExtractKey(T) string // Extract the key from the parent data. Will be given to the Fetch function afterwards. - Fetch(Executor, context.Context, KeyedResult[T]) error // Fetch related data represented by this dataloader. + ExtractKey(T) string // Extract the key from the parent data. Will be given to the Fetch function afterwards. + Fetch(Executor, context.Context, storage.KeyedResult[T]) error // Fetch related data represented by this dataloader. } dataLoader[T any] struct { extractor func(T) string - fetcher func(Executor, context.Context, KeyedResult[T]) error + fetcher func(Executor, context.Context, storage.KeyedResult[T]) error } ) // Builds up a new dataloader. func NewDataloader[T any]( extractor func(T) string, - fetcher func(Executor, context.Context, KeyedResult[T]) error, + fetcher func(Executor, context.Context, storage.KeyedResult[T]) error, ) Dataloader[T] { return &dataLoader[T]{extractor, fetcher} } @@ -29,6 +33,6 @@ func (l *dataLoader[T]) ExtractKey(data T) string { return l.extractor(data) } -func (l *dataLoader[T]) Fetch(ex Executor, ctx context.Context, result KeyedResult[T]) error { +func (l *dataLoader[T]) Fetch(ex Executor, ctx context.Context, result storage.KeyedResult[T]) error { return l.fetcher(ex, ctx, result) } diff --git a/pkg/storage/sqlite/builder/result.go b/pkg/storage/sqlite/builder/result.go index 3b60e52..a190826 100644 --- a/pkg/storage/sqlite/builder/result.go +++ b/pkg/storage/sqlite/builder/result.go @@ -3,20 +3,20 @@ package builder import "golang.org/x/exp/maps" type ( - KeysMapping map[string]int + keysMapping map[string]int // Represents a key indexed set of data. - KeyedResult[T any] struct { + keyedResult[T any] struct { data []T - indexByKeys KeysMapping + indexByKeys keysMapping } ) // Keys returns the list of keys contained in this dataset. -func (r KeyedResult[T]) Keys() []string { return maps.Keys(r.indexByKeys) } +func (r *keyedResult[T]) Keys() []string { return maps.Keys(r.indexByKeys) } // Update the result with the given key by applying the given function if it exists. -func (r *KeyedResult[T]) Update(targetKey string, updateFn func(T) T) { +func (r *keyedResult[T]) Update(targetKey string, updateFn func(T) T) { idx, found := r.indexByKeys[targetKey] if !found {