From 73551d334dd2a4e929e6139d5949cfe3610a1b94 Mon Sep 17 00:00:00 2001 From: Julien LEICHER Date: Mon, 23 Sep 2024 11:02:07 +0200 Subject: [PATCH] feat: manual proxy configuration, closes #58 Also includes some tests refactoring to move towards better coverage and simpler unit tests #63. --- .dockerignore | 1 - Dockerfile | 2 +- .../front/src/components/target-card.svelte | 3 +- cmd/serve/front/src/lib/localization/en.ts | 10 +- cmd/serve/front/src/lib/localization/fr.ts | 9 +- cmd/serve/front/src/lib/resources/apps.ts | 2 +- cmd/serve/front/src/lib/resources/targets.ts | 6 +- .../src/routes/(main)/apps/app-form.svelte | 47 +- .../src/routes/(main)/apps/service-url.svelte | 15 + .../(main)/targets/[id]/edit/+page.svelte | 2 +- .../routes/(main)/targets/target-form.svelte | 21 +- docs/reference/providers/docker.md | 2 +- docs/reference/targets.md | 11 +- .../app/create_target/create_target.go | 30 +- .../app/create_target/create_target_test.go | 44 +- .../expose_seelf_container.go | 5 +- .../deployment/app/get_target/get_target.go | 2 +- internal/deployment/app/query.go | 6 +- .../app/update_target/update_target.go | 14 +- .../app/update_target/update_target_test.go | 37 +- internal/deployment/domain/service.go | 4 +- internal/deployment/domain/target.go | 62 +- internal/deployment/domain/target_test.go | 1306 ++++++++++------- internal/deployment/fixture/deployment.go | 6 + internal/deployment/fixture/target.go | 46 +- .../infra/artifact/local_artifact_manager.go | 8 +- .../infra/provider/docker/client.go | 4 +- .../infra/provider/docker/deployment.go | 64 +- .../infra/provider/docker/provider.go | 31 +- .../infra/provider/docker/provider_test.go | 1152 +++++++++------ .../deployment/infra/provider/docker/proxy.go | 24 +- internal/deployment/infra/provider/facade.go | 4 +- .../deployment/infra/provider/facade_test.go | 30 +- .../migrations/1706004450_add_target.up.sql | 2 + .../1726473707_target_url_optional.up.sql | 55 + internal/deployment/infra/sqlite/targets.go | 8 +- pkg/storage/sqlite/builder/builder.go | 2 +- 37 files changed, 1866 insertions(+), 1211 deletions(-) create mode 100644 cmd/serve/front/src/routes/(main)/apps/service-url.svelte create mode 100644 internal/deployment/infra/sqlite/migrations/1726473707_target_url_optional.up.sql diff --git a/.dockerignore b/.dockerignore index 33ee2a23..99c4d3ca 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,4 @@ examples -Makefile Dockerfile compose.yml docs diff --git a/Dockerfile b/Dockerfile index e50fd5a7..9fc9d3d9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,7 +5,7 @@ RUN npm ci COPY ./cmd/serve/front . RUN npm run build -FROM golang:1.21-alpine AS builder +FROM golang:1.23-alpine AS builder # build-base needed to compile the sqlite3 dependency RUN apk add --update-cache build-base WORKDIR /app diff --git a/cmd/serve/front/src/components/target-card.svelte b/cmd/serve/front/src/components/target-card.svelte index 883b6c5a..a62fc73f 100644 --- a/cmd/serve/front/src/components/target-card.svelte +++ b/cmd/serve/front/src/components/target-card.svelte @@ -5,6 +5,7 @@ import Stack from '$components/stack.svelte'; import CleanupNotice from '$components/cleanup-notice.svelte'; import routes from '$lib/path'; + import l from '$lib/localization'; import { type Target, TargetStatus } from '$lib/resources/targets'; export let data: Target; @@ -27,7 +28,7 @@

{data.name}

-
{data.url}
+
{data.url ?? l.translate('target.manual_proxy')}
diff --git a/cmd/serve/front/src/lib/localization/en.ts b/cmd/serve/front/src/lib/localization/en.ts index e890a111..8ee82f53 100644 --- a/cmd/serve/front/src/lib/localization/en.ts +++ b/cmd/serve/front/src/lib/localization/en.ts @@ -7,8 +7,7 @@ const translations = { 'auth.signin.description': 'Please fill the form below to access your dashboard.', // App 'app.no_targets': 'No targets found', - 'app.no_targets.description': - 'You need at least one target to deploy your application. Head to the create target page to create one.', + 'app.no_targets.description': `You need at least one target to deploy your application. Head to the create target page to create one.`, 'app.not_found': "Looks like the application you're looking for does not exist. Head back to the", 'app.not_found.cta': 'homepage', 'app.blankslate': `Looks like you have no application yet.
Applications represents services you want to deploy on your infrastructure. Start by creating one!`, @@ -43,8 +42,8 @@ This action is IRREVERSIBLE and will DELETE ALL DATA associated with this applic 'app.environment.staging': 'Staging settings', 'app.environment.target': 'Deploy target', 'app.environment.target.changed': 'Target changed', - 'app.environment.target.changed.description': (url: string) => - `If you change the target, resources related to this application deployed by seelf on ${url} will be REMOVED and a new deployment on the new target will be queued if possible. If you want to backup something, do it before updating the target.`, + 'app.environment.target.changed.description': (name: string) => + `If you change the target, resources related to this application deployed by seelf on ${name} will be REMOVED and a new deployment on the new target will be queued if possible. If you want to backup something, do it before updating the target.`, 'app.environment.vars': 'Environment variables', 'app.environment.vars.service.add': 'Add service variables', 'app.environment.vars.service.delete': 'Remove service variables', @@ -77,6 +76,9 @@ This action is IRREVERSIBLE and will DELETE ALL DATA associated with this applic 'target.blankslate': `Looks like you have no target yet.
Targets determine on which host your applications will be deployed and which provider should be used. Start by creating one!`, 'target.general': 'General settings', 'target.name.help': 'The name is being used only for display, it can be anything you want.', + 'target.manual_proxy': 'Manual proxy', + 'target.automatic_proxy_configuration': 'Expose services automatically', + 'target.automatic_proxy_configuration.help': `If enabled, a proxy will be deployed on the target and your services will be automatically exposed. If disabled, you will have to manually expose your services with your preferred solution. Updating this setting may require you to redeploy your applications.`, 'target.url.help': 'All applications deployed on this target will be available as a subdomain on this root URL (without path). It should be unique among targets. You MUST configure a wildcard DNS for subdomains such as *.<url above> redirects to this target IP.', 'target.provider': 'Provider', diff --git a/cmd/serve/front/src/lib/localization/fr.ts b/cmd/serve/front/src/lib/localization/fr.ts index 85058e3d..1bb645ea 100644 --- a/cmd/serve/front/src/lib/localization/fr.ts +++ b/cmd/serve/front/src/lib/localization/fr.ts @@ -11,7 +11,7 @@ export default { 'Remplissez le formulaire ci-dessous pour accéder au tableau de bord.', // App 'app.no_targets': 'Aucune cible trouvée', - 'app.no_targets.description': `Vous avez besoin d'au moins une cible pour pouvoir déployer votre application. Dirigez-vous vers la page de création pour en créer une.`, + 'app.no_targets.description': `Vous avez besoin d'au moins une cible pour pouvoir déployer votre application. Dirigez-vous vers la page de création pour en créer une.`, 'app.not_found': "Il semblerait que l'application que vous recherchez n'existe pas. Retournez à la", 'app.not_found.cta': "page d'accueil", @@ -47,8 +47,8 @@ Cette action est IRRÉVERSIBLE et supprimera TOUTES LES DONNÉES associées sur 'app.environment.staging': 'Paramètres de staging', 'app.environment.target': 'Cible de déploiement', 'app.environment.target.changed': 'Cible mise à jour', - 'app.environment.target.changed.description': (url: string) => - `Si vous changez de cible, toutes les ressources liées à cette application déployées par seelf sur ${url} seront SUPPRIMÉES et un déploiement sur la nouvelle cible sera programmé si possible. Si vous devez sauvegarder quelque chose, faites le avant de changer la cible.`, + 'app.environment.target.changed.description': (name: string) => + `Si vous changez de cible, toutes les ressources liées à cette application déployées par seelf sur ${name} seront SUPPRIMÉES et un déploiement sur la nouvelle cible sera programmé si possible. Si vous devez sauvegarder quelque chose, faites le avant de changer la cible.`, 'app.environment.vars': "Variables d'environnement", 'app.environment.vars.service.add': 'Ajouter un service', 'app.environment.vars.service.delete': 'Supprimer le service', @@ -83,6 +83,9 @@ Cette action est IRRÉVERSIBLE et supprimera TOUTES LES DONNÉES associées sur 'target.blankslate': `Aucune cible pour le moment.
Les cibles déterminent sur quel hôte vos applications seront déployées. Commencez par en créer une !`, 'target.general': 'Paramètres généraux', 'target.name.help': `Le nom est utilisé uniquement pour l'affichage. Vous pouvez choisir ce que vous voulez.`, + 'target.manual_proxy': 'Proxy manuel', + 'target.automatic_proxy_configuration': 'Exposer les services automatiquement', + 'target.automatic_proxy_configuration.help': `Si activé, un proxy sera déployé sur la cible et vos services seront automatiquement exposés. Si désactivé, vous devrez faire le nécessaire pour rendre vos services accessibles en utilisant la méthode de votre choix. Changer ce paramètre pourra nécessiter le redéploiement de vos applications.`, 'target.url.help': `Toutes les applications déployées sur cette cible seront disponibles en tant que sous-domaine de cette URL racine (sans sous-chemin). Elle doit être unique parmi les cibles. Vous DEVEZ configurer un DNS wildcard pour les sous-domaines de telle sorte que *.<url configurée> redirige vers l'IP de cette cible.`, 'target.provider': 'Fournisseur', 'target.provider.docker.help': "Docker engine DOIT être installé sur l'hôte.", diff --git a/cmd/serve/front/src/lib/resources/apps.ts b/cmd/serve/front/src/lib/resources/apps.ts index 04a37fb4..cdc213e1 100644 --- a/cmd/serve/front/src/lib/resources/apps.ts +++ b/cmd/serve/front/src/lib/resources/apps.ts @@ -25,7 +25,7 @@ export type VersionControl = { url: string; token?: string }; export type TargetSummary = { id: string; name: string; - url: string; + url?: string; }; export type AppDetail = { diff --git a/cmd/serve/front/src/lib/resources/targets.ts b/cmd/serve/front/src/lib/resources/targets.ts index d65e0398..3ef4402a 100644 --- a/cmd/serve/front/src/lib/resources/targets.ts +++ b/cmd/serve/front/src/lib/resources/targets.ts @@ -29,7 +29,7 @@ export type ProviderTypes = ProviderConfigData['kind']; export type Target = { id: string; name: string; - url: string; + url?: string; provider: ProviderConfigData; state: TargetState; cleanup_requested_at?: string; @@ -39,7 +39,7 @@ export type Target = { export type CreateTarget = { name: string; - url: string; + url?: string; docker?: { host?: string; user?: string; @@ -50,7 +50,7 @@ export type CreateTarget = { export type UpdateTarget = { name?: string; - url?: string; + url: Patch; docker?: { host?: string; user?: string; diff --git a/cmd/serve/front/src/routes/(main)/apps/app-form.svelte b/cmd/serve/front/src/routes/(main)/apps/app-form.svelte index 80a1878f..56ff9f68 100644 --- a/cmd/serve/front/src/routes/(main)/apps/app-form.svelte +++ b/cmd/serve/front/src/routes/(main)/apps/app-form.svelte @@ -18,6 +18,7 @@ import type { Target } from '$lib/resources/targets'; import l from '$lib/localization'; import Dropdown, { type DropdownOption } from '$components/dropdown.svelte'; + import ServiceUrl from './service-url.svelte'; export let handler: (data: any) => Promise; export let targets: Target[]; @@ -40,8 +41,8 @@ let prodScheme = l.translate('app.how.placeholder.scheme'); let prodUrl = l.translate('app.how.placeholder.url'); - let stagingScheme = l.translate('app.how.placeholder.scheme'); - let stagingUrl = l.translate('app.how.placeholder.url'); + let stagingScheme = prodScheme; + let stagingUrl = prodUrl; const targetsMap = targets.reduce>((acc, value) => { acc[value.id] = value; @@ -51,19 +52,23 @@ $: appName = name || l.translate('app.how.placeholder.name'); $: { try { - const u = new URL(targetsMap[production.target]?.url); + const u = new URL(targetsMap[production.target]?.url!); prodScheme = u.protocol + '//'; prodUrl = u.hostname; - } catch {} + } catch { + prodScheme = prodUrl = ''; + } } $: { try { - const u = new URL(targetsMap[staging.target]?.url); + const u = new URL(targetsMap[staging.target]?.url!); stagingScheme = u.protocol + '//'; stagingUrl = u.hostname; - } catch {} + } catch { + stagingScheme = stagingUrl = ''; + } } const environmentText = l.translate('app.how.env'); @@ -73,7 +78,7 @@ const targetsOptions = targets.map((target) => ({ value: target.id, - label: `${target.url} - ${target.name}` + label: `${target.url ?? l.translate('target.manual_proxy')} - ${target.name}` })) satisfies DropdownOption[]; // Type $$Props to narrow the handler function based on wether this is an update or a new app @@ -167,19 +172,35 @@ production - {prodScheme}{appName}.{prodUrl} + - {prodScheme}dashboard.{appName}.{prodUrl} + staging - {stagingScheme}{appName}-staging.{stagingUrl} + - {stagingScheme}dashboard.{appName}-staging.{stagingUrl} + @@ -223,7 +244,7 @@

{@html l.translate('app.environment.target.changed.description', [ - initialData.production.target.url + initialData.production.target.name ])}

@@ -252,7 +273,7 @@

{@html l.translate('app.environment.target.changed.description', [ - initialData.production.target.url + initialData.production.target.name ])}

diff --git a/cmd/serve/front/src/routes/(main)/apps/service-url.svelte b/cmd/serve/front/src/routes/(main)/apps/service-url.svelte new file mode 100644 index 00000000..96419b33 --- /dev/null +++ b/cmd/serve/front/src/routes/(main)/apps/service-url.svelte @@ -0,0 +1,15 @@ + + +{#if scheme} + {scheme}{prefix}{appName}{suffix}.{host} +{:else} + - ({l.translate('target.manual_proxy')}) +{/if} diff --git a/cmd/serve/front/src/routes/(main)/targets/[id]/edit/+page.svelte b/cmd/serve/front/src/routes/(main)/targets/[id]/edit/+page.svelte index 844e8a06..8cae3108 100644 --- a/cmd/serve/front/src/routes/(main)/targets/[id]/edit/+page.svelte +++ b/cmd/serve/front/src/routes/(main)/targets/[id]/edit/+page.svelte @@ -14,7 +14,7 @@ export let data; const submit = (d: UpdateTarget) => - service.update(data.target.id, d).then((t) => goto(routes.targets)); + service.update(data.target.id, d).then(() => goto(routes.targets)); const { loading: deleting, diff --git a/cmd/serve/front/src/routes/(main)/targets/target-form.svelte b/cmd/serve/front/src/routes/(main)/targets/target-form.svelte index b12201ba..bc9b4855 100644 --- a/cmd/serve/front/src/routes/(main)/targets/target-form.svelte +++ b/cmd/serve/front/src/routes/(main)/targets/target-form.svelte @@ -26,7 +26,8 @@ let name = initialData?.name ?? ''; let url = initialData?.url ?? ''; let provider: ProviderTypes = initialData?.provider.kind ?? providerTypes[0]; - let isRemote = !!initialData?.provider.data.host ?? false; + let isRemote = !!initialData?.provider.data.host || false; + let automaticProxyConfiguration = initialData ? !!initialData.url : true; const docker = { ...initialData?.provider.data }; @@ -48,7 +49,7 @@ if (!initialData) { formData = { name, - url, + url: automaticProxyConfiguration ? url : undefined, docker: provider === 'docker' ? isRemote @@ -64,7 +65,7 @@ } else { formData = { name, - url, + url: automaticProxyConfiguration ? (initialData?.url !== url ? url : undefined) : null, docker: provider === 'docker' ? isRemote @@ -114,9 +115,17 @@

{l.translate('target.name.help')}

- -

{@html l.translate('target.url.help')}

-
+ +

{@html l.translate('target.automatic_proxy_configuration.help')}

+
+ {#if automaticProxyConfiguration} + +

{@html l.translate('target.url.help')}

+
+ {/if} diff --git a/docs/reference/providers/docker.md b/docs/reference/providers/docker.md index 6a51513c..6f1795d4 100644 --- a/docs/reference/providers/docker.md +++ b/docs/reference/providers/docker.md @@ -35,7 +35,7 @@ Where `ENVIRONMENT` will be one of `production`, `staging`. ## Exposing services -Once a valid compose file has been found, **seelf** will apply some **heuristics** to determine which services should be exposed and where. +Once a valid compose file has been found and **only if** the target [manages the proxy itself](/reference/targets#proxy), **seelf** will apply some **heuristics** to determine which services should be exposed and where. It will consider any service with **port mappings** to be exposed. diff --git a/docs/reference/targets.md b/docs/reference/targets.md index 5dedd74a..9d24c58c 100644 --- a/docs/reference/targets.md +++ b/docs/reference/targets.md @@ -6,9 +6,16 @@ Targets represents an **host** where your deployments will be exposed. When conf For now, only one target per host is allowed. ::: -## Url +## Proxy configuration {#proxy} -The url **determine where your applications will be made available**. It should be a **root url** as applications will use subdomains on it. +When declaring a target, you must choose how the proxy (needed to make your services available from the outside world) should be managed: + +- **Automatic**: **seelf** will deploy and configure a [traefik](https://traefik.io/traefik/) proxy on the target. Services urls will be automatically generated based on the [target's url](#url) and [service file](/reference/providers/docker#exposing-services) when deploying. Exposed services will also join the proxy network. +- **Manual**: you're in charge of **everything** related to services exposure. **seelf** will deploy services on this target without attempting to expose them in any way. + +### Url + +If the target manages the proxy itself, this url **determines where your applications will be made available**. It should be a **root url** as applications will use subdomains on it. The scheme associated with this url (`http` or `https`) will determine if certificates should be generated or not. diff --git a/internal/deployment/app/create_target/create_target.go b/internal/deployment/app/create_target/create_target.go index cbf67faf..7636348f 100644 --- a/internal/deployment/app/create_target/create_target.go +++ b/internal/deployment/app/create_target/create_target.go @@ -6,6 +6,7 @@ import ( auth "github.com/YuukanOO/seelf/internal/auth/domain" "github.com/YuukanOO/seelf/internal/deployment/domain" "github.com/YuukanOO/seelf/pkg/bus" + "github.com/YuukanOO/seelf/pkg/monad" "github.com/YuukanOO/seelf/pkg/validate" "github.com/YuukanOO/seelf/pkg/validate/strings" ) @@ -13,9 +14,9 @@ import ( type Command struct { bus.Command[string] - Name string `json:"name"` - Url string `json:"url"` - Provider any `json:"-"` + Name string `json:"name"` + Url monad.Maybe[string] `json:"url"` + Provider any `json:"-"` } func (Command) Name_() string { return "deployment.command.create_target" } @@ -30,7 +31,9 @@ func Handler( if err := validate.Struct(validate.Of{ "name": validate.Field(cmd.Name, strings.Required), - "url": validate.Value(cmd.Url, &targetUrl, domain.UrlFrom), + "url": validate.Maybe(cmd.Url, func(url string) error { + return validate.Value(url, &targetUrl, domain.UrlFrom) + }), }); err != nil { return "", err } @@ -42,10 +45,14 @@ func Handler( } // Validate availability of both the target domain and the config - urlRequirement, err := reader.CheckUrlAvailability(ctx, targetUrl) + var urlRequirement domain.TargetUrlRequirement - if err != nil { - return "", err + if cmd.Url.HasValue() { + urlRequirement, err = reader.CheckUrlAvailability(ctx, targetUrl) + + if err != nil { + return "", err + } } configRequirement, err := reader.CheckConfigAvailability(ctx, config) @@ -55,7 +62,7 @@ func Handler( } if err = validate.Struct(validate.Of{ - "url": urlRequirement.Error(), + "url": validate.If(cmd.Url.HasValue(), urlRequirement.Error), config.Kind(): configRequirement.Error(), }); err != nil { return "", err @@ -63,7 +70,6 @@ func Handler( target, err := domain.NewTarget( cmd.Name, - urlRequirement, configRequirement, auth.CurrentUser(ctx).MustGet(), ) @@ -72,6 +78,12 @@ func Handler( return "", err } + if cmd.Url.HasValue() { + if err = target.ExposeServicesAutomatically(urlRequirement); err != nil { + return "", err + } + } + if err = writer.Write(ctx, &target); err != nil { return "", err } diff --git a/internal/deployment/app/create_target/create_target_test.go b/internal/deployment/app/create_target/create_target_test.go index 20d2f29a..02ce76fe 100644 --- a/internal/deployment/app/create_target/create_target_test.go +++ b/internal/deployment/app/create_target/create_target_test.go @@ -12,6 +12,7 @@ import ( "github.com/YuukanOO/seelf/pkg/bus" "github.com/YuukanOO/seelf/pkg/bus/spy" shared "github.com/YuukanOO/seelf/pkg/domain" + "github.com/YuukanOO/seelf/pkg/monad" "github.com/YuukanOO/seelf/pkg/must" "github.com/YuukanOO/seelf/pkg/validate" "github.com/YuukanOO/seelf/pkg/validate/strings" @@ -35,7 +36,6 @@ func Test_CreateTarget(t *testing.T) { assert.ValidationError(t, validate.FieldErrors{ "name": strings.ErrRequired, - "url": domain.ErrInvalidUrl, }, err) }) @@ -45,14 +45,14 @@ func Test_CreateTarget(t *testing.T) { target := fixture.Target( fixture.WithTargetCreatedBy(user.ID()), fixture.WithProviderConfig(config), - fixture.WithTargetUrl(must.Panic(domain.UrlFrom("http://example.com"))), ) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true))) handler, ctx, _ := arrange(t, fixture.WithUsers(&user), fixture.WithTargets(&target)) _, err := handler(ctx, create_target.Command{ Name: "target", - Url: "http://example.com", + Url: monad.Value("http://example.com"), Provider: config, }) @@ -67,12 +67,35 @@ func Test_CreateTarget(t *testing.T) { _, err := handler(ctx, create_target.Command{ Name: "target", - Url: "http://example.com", }) assert.ErrorIs(t, domain.ErrNoValidProviderFound, err) }) + t.Run("should allow multiple manual targets to co-exists", func(t *testing.T) { + user := authfixture.User() + target := fixture.Target(fixture.WithTargetCreatedBy(user.ID())) + handler, ctx, dispatcher := arrange(t, fixture.WithUsers(&user), fixture.WithTargets(&target)) + + id, err := handler(ctx, create_target.Command{ + Name: "target-one", + Provider: fixture.ProviderConfig(), + }) + + assert.Nil(t, err) + assert.NotZero(t, id) + + id, err = handler(ctx, create_target.Command{ + Name: "target-two", + Provider: fixture.ProviderConfig(), + }) + + assert.Nil(t, err) + assert.NotZero(t, id) + + assert.HasLength(t, 2, dispatcher.Signals()) + }) + t.Run("should create a new target", func(t *testing.T) { var config = fixture.ProviderConfig() user := authfixture.User() @@ -80,24 +103,31 @@ func Test_CreateTarget(t *testing.T) { id, err := handler(ctx, create_target.Command{ Name: "target", - Url: "http://example.com", + Url: monad.Value("http://example.com"), Provider: config, }) assert.Nil(t, err) assert.NotZero(t, id) - assert.HasLength(t, 1, dispatcher.Signals()) + assert.HasLength(t, 3, dispatcher.Signals()) created := assert.Is[domain.TargetCreated](t, dispatcher.Signals()[0]) assert.DeepEqual(t, domain.TargetCreated{ ID: domain.TargetID(id), Name: "target", - Url: must.Panic(domain.UrlFrom("http://example.com")), State: created.State, Entrypoints: make(domain.TargetEntrypoints), Provider: config, // Since the mock returns the config "as is" Created: shared.ActionFrom(user.ID(), assert.NotZero(t, created.Created.At())), }, created) + + urlChanged := assert.Is[domain.TargetUrlChanged](t, dispatcher.Signals()[1]) + assert.Equal(t, domain.TargetUrlChanged{ + ID: domain.TargetID(id), + Url: must.Panic(domain.UrlFrom("http://example.com")), + }, urlChanged) + + assert.Is[domain.TargetStateChanged](t, dispatcher.Signals()[2]) }) } diff --git a/internal/deployment/app/expose_seelf_container/expose_seelf_container.go b/internal/deployment/app/expose_seelf_container/expose_seelf_container.go index 004bd130..f8658ea3 100644 --- a/internal/deployment/app/expose_seelf_container/expose_seelf_container.go +++ b/internal/deployment/app/expose_seelf_container/expose_seelf_container.go @@ -78,7 +78,6 @@ func Handler( } target, err = domain.NewTarget("local", - urlRequirement, configRequirement, auth.CurrentUser(ctx).MustGet(), ) @@ -87,6 +86,10 @@ func Handler( return bus.Unit, err } + if err = target.ExposeServicesAutomatically(urlRequirement); err != nil { + return bus.Unit, err + } + assigned, err := provider.Setup(ctx, target) target.Configured(target.CurrentVersion(), assigned, err) diff --git a/internal/deployment/app/get_target/get_target.go b/internal/deployment/app/get_target/get_target.go index 542b7775..74a50379 100644 --- a/internal/deployment/app/get_target/get_target.go +++ b/internal/deployment/app/get_target/get_target.go @@ -22,7 +22,7 @@ type ( Target struct { ID string `json:"id"` Name string `json:"name"` - Url string `json:"url"` + Url monad.Maybe[string] `json:"url"` Provider Provider `json:"provider"` State State `json:"state"` CleanupRequestedAt monad.Maybe[time.Time] `json:"cleanup_requested_at"` diff --git a/internal/deployment/app/query.go b/internal/deployment/app/query.go index 98f0cd46..eb7f82b8 100644 --- a/internal/deployment/app/query.go +++ b/internal/deployment/app/query.go @@ -9,9 +9,9 @@ type ( } TargetSummary struct { - ID string `json:"id"` - Name string `json:"name"` - Url string `json:"url"` + ID string `json:"id"` + Name string `json:"name"` + Url monad.Maybe[string] `json:"url"` } LatestDeployments[T any] struct { diff --git a/internal/deployment/app/update_target/update_target.go b/internal/deployment/app/update_target/update_target.go index c8e3fa8d..e99c34cd 100644 --- a/internal/deployment/app/update_target/update_target.go +++ b/internal/deployment/app/update_target/update_target.go @@ -15,7 +15,7 @@ type Command struct { ID string `json:"-"` Name monad.Maybe[string] `json:"name"` - Url monad.Maybe[string] `json:"url"` + Url monad.Patch[string] `json:"url"` Provider any `json:"-"` } @@ -31,7 +31,7 @@ func Handler( if err := validate.Struct(validate.Of{ "name": validate.Maybe(cmd.Name, strings.Required), - "url": validate.Maybe(cmd.Url, func(s string) error { + "url": validate.Patch(cmd.Url, func(s string) error { return validate.Value(s, &targetUrl, domain.UrlFrom) }), }); err != nil { @@ -87,8 +87,14 @@ func Handler( } } - if cmd.Url.HasValue() { - if err = target.HasUrl(urlRequirement); err != nil { + if cmd.Url.IsSet() { + if cmd.Url.HasValue() { + err = target.ExposeServicesAutomatically(urlRequirement) + } else { + err = target.ExposeServicesManually() + } + + if err != nil { return "", err } } diff --git a/internal/deployment/app/update_target/update_target_test.go b/internal/deployment/app/update_target/update_target_test.go index 1e2a0612..28d29f29 100644 --- a/internal/deployment/app/update_target/update_target_test.go +++ b/internal/deployment/app/update_target/update_target_test.go @@ -40,13 +40,13 @@ func Test_UpdateTarget(t *testing.T) { config := fixture.ProviderConfig() targetOne := fixture.Target( fixture.WithTargetCreatedBy(user.ID()), - fixture.WithTargetUrl(must.Panic(domain.UrlFrom("http://localhost"))), fixture.WithProviderConfig(config), ) + assert.Nil(t, targetOne.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://localhost")), true))) targetTwo := fixture.Target( fixture.WithTargetCreatedBy(user.ID()), - fixture.WithTargetUrl(must.Panic(domain.UrlFrom("http://docker.localhost"))), ) + assert.Nil(t, targetTwo.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) handler, _ := arrange(t, fixture.WithUsers(&user), fixture.WithTargets(&targetOne, &targetTwo), @@ -55,7 +55,7 @@ func Test_UpdateTarget(t *testing.T) { _, err := handler(context.Background(), update_target.Command{ ID: string(targetTwo.ID()), Provider: config, - Url: monad.Value("http://localhost"), + Url: monad.PatchValue("http://localhost"), }) assert.ValidationError(t, validate.FieldErrors{ @@ -64,23 +64,48 @@ func Test_UpdateTarget(t *testing.T) { }, err) }) - t.Run("should update the target if everything is good", func(t *testing.T) { + t.Run("should be able to remove the url", func(t *testing.T) { user := authfixture.User() target := fixture.Target( fixture.WithTargetCreatedBy(user.ID()), fixture.WithProviderConfig(fixture.ProviderConfig(fixture.WithFingerprint("test"))), ) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) + handler, dispatcher := arrange(t, + fixture.WithUsers(&user), + fixture.WithTargets(&target), + ) + + _, err := handler(context.Background(), update_target.Command{ + ID: string(target.ID()), + Url: monad.Nil[string](), + }) + + assert.Nil(t, err) + assert.HasLength(t, 2, dispatcher.Signals()) + urlRemoved := assert.Is[domain.TargetUrlRemoved](t, dispatcher.Signals()[0]) + assert.Equal(t, domain.TargetUrlRemoved{ + ID: target.ID(), + }, urlRemoved) + }) + + t.Run("should update the target if everything is good", func(t *testing.T) { + user := authfixture.User() + target := fixture.Target( + fixture.WithTargetCreatedBy(user.ID()), + fixture.WithProviderConfig(fixture.ProviderConfig(fixture.WithFingerprint("test"), fixture.WithKind("test"))), + ) handler, dispatcher := arrange(t, fixture.WithUsers(&user), fixture.WithTargets(&target), ) - newConfig := fixture.ProviderConfig(fixture.WithFingerprint("test")) + newConfig := fixture.ProviderConfig(fixture.WithFingerprint("test"), fixture.WithKind("test")) id, err := handler(context.Background(), update_target.Command{ ID: string(target.ID()), Name: monad.Value("new name"), Provider: newConfig, - Url: monad.Value("http://docker.localhost"), + Url: monad.PatchValue("http://docker.localhost"), }) assert.Nil(t, err) diff --git a/internal/deployment/domain/service.go b/internal/deployment/domain/service.go index 20c7dc8e..55b0c61d 100644 --- a/internal/deployment/domain/service.go +++ b/internal/deployment/domain/service.go @@ -155,7 +155,7 @@ func (e EntrypointName) Protocol() string { return string(p) } -// Retrieve entrypoints for this service. +// Retrieve all entrypoints for every services. func (s Services) Entrypoints() []Entrypoint { var result []Entrypoint @@ -166,7 +166,7 @@ func (s Services) Entrypoints() []Entrypoint { return result } -// Retrieve custom entrypoints for this service. Ones that are not natively +// Retrieve all custom entrypoints. Ones that are not natively // managed by the target and requires a manual configuration. func (s Services) CustomEntrypoints() []Entrypoint { return slices.DeleteFunc(s.Entrypoints(), isNotCustom) diff --git a/internal/deployment/domain/target.go b/internal/deployment/domain/target.go index f64cc6bd..14df8015 100644 --- a/internal/deployment/domain/target.go +++ b/internal/deployment/domain/target.go @@ -43,7 +43,7 @@ type ( id TargetID name string - url Url + url monad.Maybe[Url] provider ProviderConfig state TargetState customEntrypoints TargetEntrypoints @@ -67,7 +67,6 @@ type ( ID TargetID Name string - Url Url Provider ProviderConfig State TargetState Entrypoints TargetEntrypoints @@ -95,6 +94,12 @@ type ( Url Url } + TargetUrlRemoved struct { + bus.Notification + + ID TargetID + } + TargetProviderChanged struct { bus.Notification @@ -127,6 +132,7 @@ func (TargetCreated) Name_() string { return "deployment.event.target func (TargetStateChanged) Name_() string { return "deployment.event.target_state_changed" } func (TargetRenamed) Name_() string { return "deployment.event.target_renamed" } func (TargetUrlChanged) Name_() string { return "deployment.event.target_url_changed" } +func (TargetUrlRemoved) Name_() string { return "deployment.event.target_url_removed" } func (TargetProviderChanged) Name_() string { return "deployment.event.target_provider_changed" } func (TargetEntrypointsChanged) Name_() string { return "deployment.event.target_entrypoints_changed" } func (TargetCleanupRequested) Name_() string { return "deployment.event.target_cleanup_requested" } @@ -139,16 +145,9 @@ func (e TargetStateChanged) WentToConfiguringState() bool { // Builds a new deployment target. func NewTarget( name string, - urlRequirement TargetUrlRequirement, providerRequirement ProviderConfigRequirement, createdBy auth.UserID, ) (t Target, err error) { - url, err := urlRequirement.Met() - - if err != nil { - return t, err - } - provider, err := providerRequirement.Met() if err != nil { @@ -158,7 +157,6 @@ func NewTarget( t.apply(TargetCreated{ ID: id.New[TargetID](), Name: name, - Url: url.Root(), Provider: provider, State: newTargetState(), Entrypoints: make(TargetEntrypoints), @@ -229,8 +227,8 @@ func (t *Target) Rename(name string) error { return nil } -// Update the internal domain used by this target. -func (t *Target) HasUrl(urlRequirement TargetUrlRequirement) error { +// Mark this target as exposing automatically services on the given root url. +func (t *Target) ExposeServicesAutomatically(urlRequirement TargetUrlRequirement) error { if t.cleanupRequested.HasValue() { return ErrTargetCleanupRequested } @@ -241,13 +239,35 @@ func (t *Target) HasUrl(urlRequirement TargetUrlRequirement) error { return err } - if t.url == url { + url = url.Root() // Remove path and query part + + if existing, isSet := t.url.TryGet(); isSet && existing == url { return nil } t.apply(TargetUrlChanged{ ID: t.id, - Url: url.Root(), + Url: url, + }) + + t.reconfigure() + + return nil +} + +// Mark this target as being manually managed by the user. The url will be removed +// and the user will have to manually manage the proxy configuration. +func (t *Target) ExposeServicesManually() error { + if t.cleanupRequested.HasValue() { + return ErrTargetCleanupRequested + } + + if !t.url.HasValue() { + return nil + } + + t.apply(TargetUrlRemoved{ + ID: t.id, }) t.reconfigure() @@ -347,7 +367,7 @@ func (t *Target) Configured(version time.Time, assigned TargetEntrypointsAssigne // If needed (new or removed entrypoints), a configuration will be triggered. func (t *Target) ExposeEntrypoints(app AppID, env Environment, services Services) { // Target is being deleted, no need to reconfigure anything - if t.cleanupRequested.HasValue() || services == nil { + if t.cleanupRequested.HasValue() { return } @@ -461,7 +481,8 @@ func (t *Target) Delete(cleanedUp bool) error { } func (t *Target) ID() TargetID { return t.id } -func (t *Target) Url() Url { return t.url } +func (t *Target) Url() monad.Maybe[Url] { return t.url } +func (t *Target) IsManual() bool { return !t.url.HasValue() } func (t *Target) Provider() ProviderConfig { return t.provider } func (t *Target) CustomEntrypoints() TargetEntrypoints { return t.customEntrypoints } // FIXME: Should we return a copy? func (t *Target) CurrentVersion() time.Time { return t.state.version } @@ -486,6 +507,10 @@ func (t *Target) raiseEntrypointsChangedAndReconfigure() { Entrypoints: t.customEntrypoints, }) + if t.IsManual() { + return + } + t.reconfigure() } @@ -494,7 +519,6 @@ func (t *Target) apply(e event.Event) { case TargetCreated: t.id = evt.ID t.name = evt.Name - t.url = evt.Url t.provider = evt.Provider t.state = evt.State t.created = evt.Created @@ -502,7 +526,9 @@ func (t *Target) apply(e event.Event) { case TargetRenamed: t.name = evt.Name case TargetUrlChanged: - t.url = evt.Url + t.url.Set(evt.Url) + case TargetUrlRemoved: + t.url.Unset() case TargetProviderChanged: t.provider = evt.Provider case TargetEntrypointsChanged: diff --git a/internal/deployment/domain/target_test.go b/internal/deployment/domain/target_test.go index 95c24b97..9ab7cbaf 100644 --- a/internal/deployment/domain/target_test.go +++ b/internal/deployment/domain/target_test.go @@ -15,664 +15,856 @@ import ( ) func Test_Target(t *testing.T) { - - t.Run("should fail if the url is not unique", func(t *testing.T) { - _, err := domain.NewTarget("target", - domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://my-url.com")), false), - domain.NewProviderConfigRequirement(fixture.ProviderConfig(), true), "uid") - - assert.ErrorIs(t, domain.ErrUrlAlreadyTaken, err) - }) - - t.Run("should fail if the config is not unique", func(t *testing.T) { - _, err := domain.NewTarget("target", - domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://my-url.com")), true), - domain.NewProviderConfigRequirement(fixture.ProviderConfig(), false), "uid") - - assert.ErrorIs(t, domain.ErrConfigAlreadyTaken, err) - }) - - t.Run("should be instantiable", func(t *testing.T) { - url := must.Panic(domain.UrlFrom("http://my-url.com")) - config := fixture.ProviderConfig() - - target, err := domain.NewTarget("target", - domain.NewTargetUrlRequirement(url, true), - domain.NewProviderConfigRequirement(config, true), - "uid") - - assert.Nil(t, err) - assert.HasNEvents(t, 1, &target) - created := assert.EventIs[domain.TargetCreated](t, &target, 0) - - assert.DeepEqual(t, domain.TargetCreated{ - ID: assert.NotZero(t, target.ID()), - Name: "target", - Url: url, - Provider: config, - State: created.State, - Entrypoints: make(domain.TargetEntrypoints), - Created: shared.ActionFrom[auth.UserID]("uid", assert.NotZero(t, created.Created.At())), - }, created) - - assert.Equal(t, domain.TargetStatusConfiguring, created.State.Status()) - assert.NotZero(t, created.State.Version()) - }) - - t.Run("could be renamed and raise the event only if different", func(t *testing.T) { - target := fixture.Target(fixture.WithTargetName("old-name")) - - err := target.Rename("new-name") - - assert.Nil(t, err) - evt := assert.EventIs[domain.TargetRenamed](t, &target, 1) - - assert.Equal(t, domain.TargetRenamed{ - ID: target.ID(), - Name: "new-name", - }, evt) - - assert.Nil(t, target.Rename("new-name")) - assert.HasNEvents(t, 2, &target, "should have raised the event once") - }) - - t.Run("could not be renamed if delete requested", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - - assert.Nil(t, target.RequestCleanup(false, "uid")) - - assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.Rename("new-name")) - }) - - t.Run("could have its domain changed if available and raise the event only if different", func(t *testing.T) { - target := fixture.Target() - newUrl := must.Panic(domain.UrlFrom("http://new-url.com")) - - err := target.HasUrl(domain.NewTargetUrlRequirement(newUrl, false)) - assert.ErrorIs(t, domain.ErrUrlAlreadyTaken, err) - - err = target.HasUrl(domain.NewTargetUrlRequirement(newUrl, true)) - assert.Nil(t, err) - evt := assert.EventIs[domain.TargetUrlChanged](t, &target, 1) - assert.Equal(t, newUrl, evt.Url) - - evtTargetChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) - assert.Equal(t, domain.TargetStatusConfiguring, evtTargetChanged.State.Status()) - - assert.Nil(t, target.HasUrl(domain.NewTargetUrlRequirement(newUrl, true))) - assert.HasNEvents(t, 3, &target) - }) - - t.Run("could not have its domain changed if delete requested", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) - - err := target.HasUrl(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://new-url.com")), true)) - - assert.ErrorIs(t, domain.ErrTargetCleanupRequested, err) - }) - - t.Run("should forbid a provider change if the fingerprint has changed", func(t *testing.T) { - target := fixture.Target(fixture.WithProviderConfig(fixture.ProviderConfig(fixture.WithFingerprint("docker")))) - - err := target.HasProvider(domain.NewProviderConfigRequirement(fixture.ProviderConfig(), true)) - - assert.ErrorIs(t, domain.ErrTargetProviderUpdateNotPermitted, err) - }) - - t.Run("could have its provider changed if available and raise the event only if different", func(t *testing.T) { - config := fixture.ProviderConfig(fixture.WithFingerprint("docker")) - target := fixture.Target(fixture.WithProviderConfig(config)) - newConfig := fixture.ProviderConfig(fixture.WithFingerprint("docker")) - - err := target.HasProvider(domain.NewProviderConfigRequirement(newConfig, false)) - - assert.ErrorIs(t, domain.ErrConfigAlreadyTaken, err) - - err = target.HasProvider(domain.NewProviderConfigRequirement(newConfig, true)) - - assert.Nil(t, err) - evt := assert.EventIs[domain.TargetProviderChanged](t, &target, 1) - assert.Equal(t, newConfig, evt.Provider) - - evtTargetChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) - assert.Equal(t, domain.TargetStatusConfiguring, evtTargetChanged.State.Status()) - - assert.Nil(t, target.HasProvider(domain.NewProviderConfigRequirement(newConfig, true))) - assert.HasNEvents(t, 3, &target, "should raise the event only once") - }) - - t.Run("could not have its provider changed if delete requested", func(t *testing.T) { - config := fixture.ProviderConfig(fixture.WithFingerprint("docker")) - target := fixture.Target(fixture.WithProviderConfig(config)) - target.Configured(target.CurrentVersion(), nil, nil) - - assert.Nil(t, target.RequestCleanup(false, "uid")) - assert.ErrorIs(t, domain.ErrTargetCleanupRequested, - target.HasProvider(domain.NewProviderConfigRequirement(fixture.ProviderConfig(fixture.WithFingerprint("docker")), true))) + // Common data used for custom entrypoints exposure + deployment := fixture.Deployment() + app := deployment.Config().NewService("app", "app-image") + app.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{ + Managed: true, }) + http := app.AddHttpEntrypoint(deployment.Config(), 3000, domain.HttpEntrypointOptions{}) + db := deployment.Config().NewService("db", "db-image") + tcp := db.AddTCPEntrypoint(5432) + + t.Run("could be created", func(t *testing.T) { + t.Run("should require a unique provider config", func(t *testing.T) { + _, err := domain.NewTarget("target", + domain.NewProviderConfigRequirement(fixture.ProviderConfig(), false), "uid") + + assert.ErrorIs(t, domain.ErrConfigAlreadyTaken, err) + }) + + t.Run("should succeed if everything is good", func(t *testing.T) { + config := fixture.ProviderConfig() + + target, err := domain.NewTarget("target", + domain.NewProviderConfigRequirement(config, true), + "uid") + + assert.Nil(t, err) + assert.Equal(t, config, target.Provider()) + assert.Zero(t, target.Url()) + assert.HasNEvents(t, 1, &target) + created := assert.EventIs[domain.TargetCreated](t, &target, 0) + + assert.DeepEqual(t, domain.TargetCreated{ + ID: assert.NotZero(t, target.ID()), + Name: "target", + Provider: config, + State: created.State, + Entrypoints: make(domain.TargetEntrypoints), + Created: shared.ActionFrom[auth.UserID]("uid", assert.NotZero(t, created.Created.At())), + }, created) + + assert.Equal(t, domain.TargetStatusConfiguring, created.State.Status()) + assert.NotZero(t, created.State.Version()) + }) + }) + + t.Run("should expose a method to check if a version is outdated or not", func(t *testing.T) { + t.Run("should return true if the version is outdated", func(t *testing.T) { + target := fixture.Target() + + assert.True(t, target.IsOutdated(target.CurrentVersion().Add(-1*time.Second))) + }) + + t.Run("should return false if the version is not outdated", func(t *testing.T) { + target := fixture.Target() + + assert.False(t, target.IsOutdated(target.CurrentVersion())) + }) + }) + + t.Run("could be renamed", func(t *testing.T) { + t.Run("should not raise the event if the name has not changed", func(t *testing.T) { + target := fixture.Target(fixture.WithTargetName("name")) + + assert.Nil(t, target.Rename("name")) + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should raise the event if the name is different", func(t *testing.T) { + target := fixture.Target(fixture.WithTargetName("old-name")) + + assert.Nil(t, target.Rename("new-name")) + assert.HasNEvents(t, 2, &target) + renamed := assert.EventIs[domain.TargetRenamed](t, &target, 1) + assert.Equal(t, domain.TargetRenamed{ + ID: target.ID(), + Name: "new-name", + }, renamed) + }) + + t.Run("should returns an error if the target cleanup has been requested", func(t *testing.T) { + target := fixture.Target(fixture.WithTargetName("old-name")) + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) + + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.Rename("new-name")) + }) + }) + + t.Run("could be configured as exposing services automatically with an url", func(t *testing.T) { + t.Run("should require the url to be unique", func(t *testing.T) { + target := fixture.Target() + + assert.ErrorIs(t, domain.ErrUrlAlreadyTaken, target.ExposeServicesAutomatically( + domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), false), + )) + }) + + t.Run("should raise the event if the url is different", func(t *testing.T) { + target := fixture.Target() + url := must.Panic(domain.UrlFrom("http://example.com")) + + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(url, true))) + + assert.HasNEvents(t, 3, &target) + urlChanged := assert.EventIs[domain.TargetUrlChanged](t, &target, 1) + assert.Equal(t, domain.TargetUrlChanged{ + ID: target.ID(), + Url: url, + }, urlChanged) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) + + t.Run("should not raise the event if the url has not changed", func(t *testing.T) { + target := fixture.Target() + url := must.Panic(domain.UrlFrom("http://example.com")) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(url, true))) + + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(url, true))) + assert.HasNEvents(t, 3, &target) + }) + + t.Run("should returns an error if the target cleanup has been requested", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) + + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.ExposeServicesAutomatically( + domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), + )) + }) + }) + + t.Run("could be configured as exposing services manually without url", func(t *testing.T) { + t.Run("should raise the event if the target had previously an url", func(t *testing.T) { + target := fixture.Target() + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true))) + + assert.Nil(t, target.ExposeServicesManually()) - t.Run("could be marked as configured and raise the appropriate event", func(t *testing.T) { - target := fixture.Target() + assert.HasNEvents(t, 5, &target) + urlRemoved := assert.EventIs[domain.TargetUrlRemoved](t, &target, 3) + assert.Equal(t, domain.TargetUrlRemoved{ + ID: target.ID(), + }, urlRemoved) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 4) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) - target.Configured(target.CurrentVersion().Add(-1*time.Hour), nil, nil) + t.Run("should not raise the event if trying to remove an url on a target without one", func(t *testing.T) { + target := fixture.Target() - assert.HasNEvents(t, 1, &target, "should not raise a new event since the version does not match") - assert.EventIs[domain.TargetCreated](t, &target, 0) - - target.Configured(target.CurrentVersion(), nil, nil) - target.Configured(target.CurrentVersion(), nil, nil) // Should not raise a new event - - assert.HasNEvents(t, 2, &target, "should raise the event once") - changed := assert.EventIs[domain.TargetStateChanged](t, &target, 1) - assert.Equal(t, domain.TargetStatusReady, changed.State.Status()) - }) - - t.Run("should handle entrypoints assignment on configuration", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - - // Assigning non existing entrypoints should just be ignored - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - deployment.ID().AppID(): { - domain.Production: { - "non-existing-entrypoint": 5432, + assert.Nil(t, target.ExposeServicesManually()) + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should returns an error if the target cleanup has been requested", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) + + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.ExposeServicesManually()) + }) + }) + + t.Run("could have its provider changed", func(t *testing.T) { + t.Run("should require the provider to be unique", func(t *testing.T) { + target := fixture.Target() + + assert.ErrorIs(t, domain.ErrConfigAlreadyTaken, + target.HasProvider(domain.NewProviderConfigRequirement(fixture.ProviderConfig(), false))) + }) + + t.Run("should require the fingerprint to be the same", func(t *testing.T) { + config := fixture.ProviderConfig(fixture.WithKind("test"), fixture.WithFingerprint("123")) + target := fixture.Target(fixture.WithProviderConfig(config)) + + assert.ErrorIs(t, domain.ErrTargetProviderUpdateNotPermitted, + target.HasProvider( + domain.NewProviderConfigRequirement( + fixture.ProviderConfig(fixture.WithKind("test"), fixture.WithFingerprint("456")), true))) + }) + + t.Run("should require the provider kind to be the same", func(t *testing.T) { + config := fixture.ProviderConfig(fixture.WithKind("test1"), fixture.WithFingerprint("123")) + target := fixture.Target(fixture.WithProviderConfig(config)) + + assert.ErrorIs(t, domain.ErrTargetProviderUpdateNotPermitted, + target.HasProvider( + domain.NewProviderConfigRequirement( + fixture.ProviderConfig(fixture.WithKind("test2"), fixture.WithFingerprint("123")), true))) + }) + + t.Run("should raise the event if the provider is different", func(t *testing.T) { + config := fixture.ProviderConfig(fixture.WithKind("test"), fixture.WithFingerprint("123")) + target := fixture.Target(fixture.WithProviderConfig(config)) + newConfig := fixture.ProviderConfig( + fixture.WithKind("test"), + fixture.WithFingerprint("123"), + fixture.WithData("some different data")) + + assert.Nil(t, target.HasProvider( + domain.NewProviderConfigRequirement(newConfig, true))) + assert.HasNEvents(t, 3, &target) + changed := assert.EventIs[domain.TargetProviderChanged](t, &target, 1) + assert.Equal(t, domain.TargetProviderChanged{ + ID: target.ID(), + Provider: newConfig, + }, changed) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) + + t.Run("should not raise the event if the provider is the same", func(t *testing.T) { + config := fixture.ProviderConfig(fixture.WithKind("test"), fixture.WithFingerprint("123")) + target := fixture.Target(fixture.WithProviderConfig(config)) + + assert.Nil(t, target.HasProvider(domain.NewProviderConfigRequirement(config, true))) + + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should returns an error if the target cleanup has been requested", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) + + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.HasProvider(domain.NewProviderConfigRequirement(fixture.ProviderConfig(), true))) + }) + }) + + t.Run("could expose custom entrypoints", func(t *testing.T) { + t.Run("should do nothing if given entrypoints are empty", func(t *testing.T) { + target := fixture.Target() + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{}) + + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should do nothing if given entrypoints are nil", func(t *testing.T) { + target := fixture.Target() + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, nil) + + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should add entrypoints", func(t *testing.T) { + t.Run("on manual target", func(t *testing.T) { + target := fixture.Target() + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + assert.HasNEvents(t, 2, &target) + changed := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 1) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): monad.None[domain.Port](), + tcp.Name(): monad.None[domain.Port](), + }, + }, + }, + }, changed) + }) + + t.Run("on automatic target", func(t *testing.T) { + target := fixture.Target() + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true))) + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + assert.HasNEvents(t, 5, &target) + changed := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 3) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): monad.None[domain.Port](), + tcp.Name(): monad.None[domain.Port](), + }, + }, + }, + }, changed) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 4) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) + }) + + t.Run("should update existing entrypoints", func(t *testing.T) { + t.Run("on manual target", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app}) + + assert.HasNEvents(t, 3, &target) + changed := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 2) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): monad.None[domain.Port](), + }, + }, + }, + }, changed) + }) + + t.Run("on automatic target", func(t *testing.T) { + target := fixture.Target() + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true))) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app}) + + assert.HasNEvents(t, 7, &target) + changed := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 5) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): monad.None[domain.Port](), + }, + }, + }, + }, changed) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 6) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) + }) + + t.Run("should not raise additional events if all entrypoints already exists", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + assert.HasNEvents(t, 2, &target) + }) + + t.Run("should be ignored if the target is being configured", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) + + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + assert.HasNEvents(t, 3, &target) + }) + }) + + t.Run("could be marked as configured", func(t *testing.T) { + t.Run("should do nothing if the version do not match", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + + target.Configured(target.CurrentVersion().Add(-1*time.Second), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, }, - }, - }, nil) + }, nil) - assert.HasNEvents(t, 2, &target) - assert.DeepEqual(t, domain.TargetEntrypoints{}, target.CustomEntrypoints()) + assert.HasNEvents(t, 2, &target) + }) - dbService := deployment.Config().NewService("db", "postgres:14-alpine") - http := dbService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - tcp := dbService.AddTCPEntrypoint(5432) + t.Run("should do nothing if the version has already been configured", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - target.ExposeEntrypoints(deployment.ID().AppID(), domain.Production, domain.Services{dbService}) + target.Configured(target.CurrentVersion(), nil, nil) - // Assigning but with an error should ignore new entrypoints - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): 8081, - tcp.Name(): 8082, - }, - }, - }, errors.New("some error")) - - assert.HasNEvents(t, 5, &target) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): monad.None[domain.Port](), - tcp.Name(): monad.None[domain.Port](), - }, - }, - }, target.CustomEntrypoints()) + assert.HasNEvents(t, 2, &target) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 1) + assert.Equal(t, domain.TargetStatusReady, stateChanged.State.Status()) + }) - assert.Nil(t, target.Reconfigure()) + t.Run("should be marked as failed if an error is given", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + err := errors.New("an error") - // No error, should update the entrypoints correctly - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): 8081, - tcp.Name(): 8082, - "non-existing-entrypoint": 5432, + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, }, - "non-existing-env": { - "non-existing-entrypoint": 5432, + }, err) + + assert.HasNEvents(t, 3, &target) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) + assert.Equal(t, domain.TargetStatusFailed, stateChanged.State.Status()) + assert.Equal(t, err.Error(), stateChanged.State.ErrCode().Get("")) + }) + + t.Run("should be marked as ready and update entrypoints with given assigned ports ignoring non-existing entrypoints", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Staging, domain.Services{app, db}) + + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + "another-app": { + domain.Production: { + "some-entrypoint": 5000, + }, }, - }, - "another-app": { - "non-existing-env": { - "non-existing-entrypoint": 5432, + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, }, - }, - }, nil) - - assert.HasNEvents(t, 8, &target) - assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 6) - changed := assert.EventIs[domain.TargetStateChanged](t, &target, 7) - assert.Equal(t, domain.TargetStatusReady, changed.State.Status()) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): monad.Value[domain.Port](8081), - tcp.Name(): monad.Value[domain.Port](8082), + }, nil) + + assert.HasNEvents(t, 5, &target) + entrypointsChanged := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 3) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Staging: { + http.Name(): monad.None[domain.Port](), + tcp.Name(): monad.None[domain.Port](), + }, + domain.Production: { + http.Name(): monad.Value[domain.Port](3000), + tcp.Name(): monad.Value[domain.Port](3001), + }, + }, }, - }, - }, target.CustomEntrypoints()) - }) - - t.Run("should be able to unexpose entrypoints for a specific app", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - dbService := deployment.Config().NewService("db", "postgres:14-alpine") - http := dbService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - tcp := dbService.AddTCPEntrypoint(5432) - - target.UnExposeEntrypoints(deployment.ID().AppID()) - - assert.HasNEvents(t, 1, &target, "should not raise an event since no entrypoints were exposed") - - target.ExposeEntrypoints(deployment.ID().AppID(), domain.Production, domain.Services{dbService}) - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): 8081, - tcp.Name(): 8082, + }, entrypointsChanged) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 4) + assert.Equal(t, domain.TargetStatusReady, stateChanged.State.Status()) + }) + }) + + t.Run("could un-expose custom entrypoints", func(t *testing.T) { + t.Run("should do nothing if not previously exposed", func(t *testing.T) { + target := fixture.Target() + + target.UnExposeEntrypoints(deployment.Config().AppID(), domain.Production) + + assert.HasNEvents(t, 1, &target) + }) + + t.Run("should un-expose all entrypoints of a given application", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints("app", domain.Production, domain.Services{app, db}) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Staging, domain.Services{app, db}) + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, + domain.Staging: { + http.Name(): 3002, + tcp.Name(): 3003, + }, }, - }, - }, nil) - - target.UnExposeEntrypoints(deployment.ID().AppID()) - - assert.HasNEvents(t, 7, &target) - assert.DeepEqual(t, domain.TargetEntrypoints{}, target.CustomEntrypoints()) - changed := assert.EventIs[domain.TargetStateChanged](t, &target, 6) - assert.Equal(t, domain.TargetStatusConfiguring, changed.State.Status()) - - target.ExposeEntrypoints(deployment.ID().AppID(), domain.Production, domain.Services{dbService}) - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - deployment.ID().AppID(): { - domain.Production: { - http.Name(): 8081, - tcp.Name(): 8082, + }, nil) + + target.UnExposeEntrypoints(deployment.Config().AppID()) + + assert.HasNEvents(t, 7, &target) + entrypointsChanged := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 6) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + "app": { + domain.Production: { + http.Name(): monad.None[domain.Port](), + tcp.Name(): monad.None[domain.Port](), + }, + }, }, - }, - }, nil) + }, entrypointsChanged) + }) + + t.Run("should un-expose all entrypoints of an application for a specific environment", func(t *testing.T) { + t.Run("on manual target", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Staging, domain.Services{app, db}) + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, + domain.Staging: { + http.Name(): 3002, + tcp.Name(): 3003, + }, + }, + }, nil) + + target.UnExposeEntrypoints(deployment.Config().AppID(), domain.Production) + + assert.HasNEvents(t, 6, &target) + entrypointsChanged := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 5) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Staging: { + http.Name(): monad.Value[domain.Port](3002), + tcp.Name(): monad.Value[domain.Port](3003), + }, + }, + }, + }, entrypointsChanged) + }) + + t.Run("on automatic target", func(t *testing.T) { + target := fixture.Target() + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("https://example.com")), true))) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Staging, domain.Services{app, db}) + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, + domain.Staging: { + http.Name(): 3002, + tcp.Name(): 3003, + }, + }, + }, nil) + + target.UnExposeEntrypoints(deployment.Config().AppID(), domain.Production) + + assert.HasNEvents(t, 11, &target) + entrypointsChanged := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 9) + assert.DeepEqual(t, domain.TargetEntrypointsChanged{ + ID: target.ID(), + Entrypoints: domain.TargetEntrypoints{ + deployment.Config().AppID(): { + domain.Staging: { + http.Name(): monad.Value[domain.Port](3002), + tcp.Name(): monad.Value[domain.Port](3003), + }, + }, + }, + }, entrypointsChanged) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 10) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) + }) + + t.Run("should be ignored if the target cleanup has been requested", func(t *testing.T) { + target := fixture.Target() + target.ExposeEntrypoints(deployment.Config().AppID(), domain.Production, domain.Services{app, db}) + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.Config().AppID(): { + domain.Production: { + http.Name(): 3000, + tcp.Name(): 3001, + }, + }, + }, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - target.UnExposeEntrypoints(deployment.ID().AppID(), domain.Staging) - target.UnExposeEntrypoints(deployment.ID().AppID(), domain.Production) + target.UnExposeEntrypoints(deployment.Config().AppID(), domain.Production) - assert.HasNEvents(t, 13, &target) - assert.DeepEqual(t, domain.TargetEntrypoints{}, target.CustomEntrypoints()) - changed = assert.EventIs[domain.TargetStateChanged](t, &target, 12) - assert.Equal(t, domain.TargetStatusConfiguring, changed.State.Status()) + assert.HasNEvents(t, 5, &target) + }) }) t.Run("could expose its availability based on its internal state", func(t *testing.T) { - target := fixture.Target() - - // Configuring - err := target.CheckAvailability() + t.Run("when configuring", func(t *testing.T) { + target := fixture.Target() - assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, err) + assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, target.CheckAvailability()) + }) - // Configuration failed - target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) + t.Run("when configuration failed", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) - err = target.CheckAvailability() + assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, target.CheckAvailability()) + }) - assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, err) + t.Run("when ready", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - // Configuration success - assert.Nil(t, target.Reconfigure()) + assert.Nil(t, target.CheckAvailability()) + }) - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("when cleanup requested", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - err = target.CheckAvailability() - - assert.Nil(t, err) - - // Delete requested - assert.Nil(t, target.RequestCleanup(false, "uid")) - - err = target.CheckAvailability() - - assert.ErrorIs(t, domain.ErrTargetCleanupRequested, err) + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.CheckAvailability()) + }) }) - t.Run("could not be reconfigured if cleanup requested", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) + t.Run("could be reconfigured", func(t *testing.T) { + t.Run("should fail if already being configured", func(t *testing.T) { + target := fixture.Target() - assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.Reconfigure()) - }) - - t.Run("could not be reconfigured if configuring", func(t *testing.T) { - target := fixture.Target() + assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, target.Reconfigure()) + }) - assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, target.Reconfigure()) - }) + t.Run("should fail if cleanup requested", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - t.Run("should not be removed if still used by an app", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + assert.ErrorIs(t, domain.ErrTargetCleanupRequested, target.Reconfigure()) + }) - assert.ErrorIs(t, domain.ErrTargetInUse, target.RequestCleanup(true, "uid")) - }) + t.Run("should succeed otherwise", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - t.Run("should not be removed if configuring", func(t *testing.T) { - target := fixture.Target() + assert.Nil(t, target.Reconfigure()) - assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, target.RequestCleanup(false, "uid")) + assert.HasNEvents(t, 3, &target) + stateChanged := assert.EventIs[domain.TargetStateChanged](t, &target, 2) + assert.Equal(t, domain.TargetStatusConfiguring, stateChanged.State.Status()) + }) }) - t.Run("could be removed if no app is using it", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("could be marked for cleanup", func(t *testing.T) { + t.Run("should returns an err if some applications are using it", func(t *testing.T) { + target := fixture.Target() - err := target.RequestCleanup(false, "uid") + assert.ErrorIs(t, domain.ErrTargetInUse, target.RequestCleanup(true, "uid")) + }) - assert.Nil(t, err) - assert.HasNEvents(t, 3, &target) - evt := assert.EventIs[domain.TargetCleanupRequested](t, &target, 2) + t.Run("should returns an err if configuring", func(t *testing.T) { + target := fixture.Target() - assert.Equal(t, domain.TargetCleanupRequested{ - ID: target.ID(), - Requested: shared.ActionFrom[auth.UserID]("uid", assert.NotZero(t, evt.Requested.At())), - }, evt) - }) + assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, target.RequestCleanup(false, "uid")) + }) - t.Run("should not raise an event if the target is already marked has deleting", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("should succeed otherwise", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) - assert.Nil(t, target.RequestCleanup(false, "uid")) + assert.Nil(t, target.RequestCleanup(false, "uid")) - assert.HasNEvents(t, 3, &target) - }) + assert.HasNEvents(t, 3, &target) + requested := assert.EventIs[domain.TargetCleanupRequested](t, &target, 2) + assert.Equal(t, domain.TargetCleanupRequested{ + ID: target.ID(), + Requested: shared.ActionFrom[auth.UserID]("uid", assert.NotZero(t, requested.Requested.At())), + }, requested) + }) - t.Run("should returns an err if trying to cleanup a target while configuring", func(t *testing.T) { - target := fixture.Target() + t.Run("should do nothing if already being cleaned up", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - _, err := target.CleanupStrategy(false) + assert.Nil(t, target.RequestCleanup(false, "uid")) - assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, err) + assert.HasNEvents(t, 3, &target) + }) }) - t.Run("should returns an err if trying to cleanup a target while deployments are still running", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("should expose a cleanup strategy to determine how the target resources should be handled", func(t *testing.T) { + t.Run("should returns an error if there are running or pending deployments on the target", func(t *testing.T) { + target := fixture.Target() - _, err := target.CleanupStrategy(true) + _, err := target.CleanupStrategy(true) - assert.ErrorIs(t, domain.ErrRunningOrPendingDeployments, err) - }) + assert.ErrorIs(t, domain.ErrRunningOrPendingDeployments, err) + }) - t.Run("should returns the skip cleanup strategy if the configuration has failed and the target could not be updated anymore", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.Reconfigure()) - target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) - assert.Nil(t, target.RequestCleanup(false, "uid")) + t.Run("should returns an error if the target is being configured", func(t *testing.T) { + target := fixture.Target() - s, err := target.CleanupStrategy(false) + _, err := target.CleanupStrategy(false) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategySkip, s) - }) + assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, err) + }) - t.Run("should returns the skip cleanup strategy if the configuration has failed and has never been reachable", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) + t.Run("should returns an error if the target configuration has failed and it has been at least ready once", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.Reconfigure()) + target.Configured(target.CurrentVersion(), nil, errors.New("failed")) - s, err := target.CleanupStrategy(false) + _, err := target.CleanupStrategy(false) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategySkip, s) - }) + assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, err) + }) - t.Run("should returns an err if the configuration has failed but the target is still updatable", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.Reconfigure()) - target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) + t.Run("should returns the skip strategy if the target has never been correctly configured and is currently failing", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, errors.New("failed")) - _, err := target.CleanupStrategy(false) + strategy, err := target.CleanupStrategy(false) - assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, err) - }) + assert.Nil(t, err) + assert.Equal(t, domain.CleanupStrategySkip, strategy) + }) - t.Run("should returns the default strategy if the target is correctly configured", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("should returns the default strategy if the target is ready", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - s, err := target.CleanupStrategy(false) + strategy, err := target.CleanupStrategy(false) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategyDefault, s) + assert.Nil(t, err) + assert.Equal(t, domain.CleanupStrategyDefault, strategy) + }) }) - t.Run("returns an err if trying to cleanup an app while configuring", func(t *testing.T) { - target := fixture.Target() + t.Run("should expose an application cleanup strategy to determine how application resources should be handled", func(t *testing.T) { + t.Run("should returns the skip strategy if the target is being cleaned up", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - _, err := target.AppCleanupStrategy(false, true) + strategy, err := target.AppCleanupStrategy(false, true) - assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, err) - }) + assert.Nil(t, err) + assert.Equal(t, domain.CleanupStrategySkip, strategy) + }) - t.Run("returns a skip strategy when trying to cleanup an app on a deleting target", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) + t.Run("should returns an error if there are still running deployments on the target for this application", func(t *testing.T) { + target := fixture.Target() - s, err := target.AppCleanupStrategy(false, false) + _, err := target.AppCleanupStrategy(true, true) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategySkip, s) - }) + assert.ErrorIs(t, domain.ErrRunningOrPendingDeployments, err) + }) - t.Run("returns a skip strategy when trying to cleanup an app when no successful deployment has been made", func(t *testing.T) { - target := fixture.Target() + t.Run("should returns the skip strategy if no successful deployment has been made and no one is running", func(t *testing.T) { + target := fixture.Target() - s, err := target.AppCleanupStrategy(false, false) + strategy, err := target.AppCleanupStrategy(false, false) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategySkip, s) - }) + assert.Nil(t, err) + assert.Equal(t, domain.CleanupStrategySkip, strategy) + }) - t.Run("returns an error when trying to cleanup an app on a failed target", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.Reconfigure()) - target.Configured(target.CurrentVersion(), nil, errors.New("configuration failed")) + t.Run("should returns an error if the target is being configured", func(t *testing.T) { + target := fixture.Target() - _, err := target.AppCleanupStrategy(false, true) + _, err := target.AppCleanupStrategy(false, true) - assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, err) - }) + assert.ErrorIs(t, domain.ErrTargetConfigurationInProgress, err) + }) - t.Run("returns an error when trying to cleanup an app but there are still running or pending deployments", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("should returns an error if the target configuration has failed", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, errors.New("failed")) - _, err := target.AppCleanupStrategy(true, false) + _, err := target.AppCleanupStrategy(false, true) - assert.ErrorIs(t, domain.ErrRunningOrPendingDeployments, err) - }) + assert.ErrorIs(t, domain.ErrTargetConfigurationFailed, err) + }) - t.Run("returns a default strategy when trying to remove an app and everything is good to process it", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) + t.Run("should returns the default strategy if the target is ready", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - s, err := target.AppCleanupStrategy(false, true) + strategy, err := target.AppCleanupStrategy(false, true) - assert.Nil(t, err) - assert.Equal(t, domain.CleanupStrategyDefault, s) + assert.Nil(t, err) + assert.Equal(t, domain.CleanupStrategyDefault, strategy) + }) }) - t.Run("should do nothing if trying to expose an empty entrypoints array", func(t *testing.T) { - target := fixture.Target() + t.Run("could be deleted", func(t *testing.T) { + t.Run("should returns an error if the target has not been mark for cleanup", func(t *testing.T) { + target := fixture.Target() - target.ExposeEntrypoints("appid", domain.Production, domain.Services{}) - assert.HasNEvents(t, 1, &target) + assert.ErrorIs(t, domain.ErrTargetCleanupNeeded, target.Delete(true)) + }) - target.ExposeEntrypoints("appid", domain.Production, nil) - assert.HasNEvents(t, 1, &target) - }) + t.Run("should returns an error if the target resources has not been cleaned up", func(t *testing.T) { + target := fixture.Target() - t.Run("should switch to the configuring state if adding new entrypoints to expose", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - appService := deployment.Config().NewService("app", "") - http := appService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - udp := appService.AddUDPEntrypoint(8080) - dbService := deployment.Config().NewService("db", "postgres:14-alpine") - tcp := dbService.AddTCPEntrypoint(5432) - - services := domain.Services{appService, dbService} - - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), services) - - assert.HasNEvents(t, 3, &target) - evt := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 1) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - deployment.Config().Environment(): { - http.Name(): monad.None[domain.Port](), - udp.Name(): monad.None[domain.Port](), - tcp.Name(): monad.None[domain.Port](), - }, - }, - }, evt.Entrypoints) + assert.ErrorIs(t, domain.ErrTargetCleanupNeeded, target.Delete(false)) + }) - changed := assert.EventIs[domain.TargetStateChanged](t, &target, 2) - assert.Equal(t, domain.TargetStatusConfiguring, changed.State.Status()) + t.Run("should succeed otherwise", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.RequestCleanup(false, "uid")) - // Should not trigger it again - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), services) - assert.HasNEvents(t, 3, &target) + assert.Nil(t, target.Delete(true)) + assert.HasNEvents(t, 4, &target) + deleted := assert.EventIs[domain.TargetDeleted](t, &target, 3) + assert.Equal(t, domain.TargetDeleted{ + ID: target.ID(), + }, deleted) + }) }) +} - t.Run("should switch to the configuring state if adding new entrypoints to an already exposed environment", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - appService := deployment.Config().NewService("app", "") - http := appService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService}) - - assert.HasNEvents(t, 3, &target) - evt := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 1) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - deployment.Config().Environment(): { - http.Name(): monad.None[domain.Port](), - }, - }, - }, evt.Entrypoints) +func Test_TargetEvents(t *testing.T) { + t.Run("should provide a function to check for configuration changes", func(t *testing.T) { + t.Run("should return false if the state is not configuring", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) - // Adding a new entrypoint should trigger new events - dbService := deployment.Config().NewService("db", "postgres:14-alpine") - tcp := dbService.AddTCPEntrypoint(5432) + evt := assert.EventIs[domain.TargetStateChanged](t, &target, 1) + assert.False(t, evt.WentToConfiguringState()) + }) - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService, dbService}) + t.Run("should return true if going to the configuring state", func(t *testing.T) { + target := fixture.Target() + target.Configured(target.CurrentVersion(), nil, nil) + assert.Nil(t, target.Reconfigure()) - assert.HasNEvents(t, 5, &target) - evt = assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 3) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - deployment.Config().Environment(): { - http.Name(): monad.None[domain.Port](), - tcp.Name(): monad.None[domain.Port](), - }, - }, - }, evt.Entrypoints) - - // Again with the same entrypoints, should trigger nothing new - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService, dbService, deployment.Config().NewService("cache", "redis:6-alpine")}) - assert.HasNEvents(t, 5, &target) + evt := assert.EventIs[domain.TargetStateChanged](t, &target, 2) + assert.True(t, evt.WentToConfiguringState()) + }) }) +} - t.Run("should switch to the configuring state if removing entrypoints", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - appService := deployment.Config().NewService("app", "") - http := appService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - appService.AddUDPEntrypoint(8080) - dbService := deployment.Config().NewService("db", "postgres:14-alpine") - tcp := dbService.AddTCPEntrypoint(5432) - - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService, dbService}) - - // Let's remove the UDP entrypoint - appService = deployment.Config().NewService("app", "") - appService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService, dbService}) - - assert.HasNEvents(t, 5, &target) - evt := assert.EventIs[domain.TargetEntrypointsChanged](t, &target, 3) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { - deployment.Config().Environment(): { - http.Name(): monad.None[domain.Port](), - tcp.Name(): monad.None[domain.Port](), - }, - }, - }, evt.Entrypoints) - }) +func Test_TargetEntrypointsAssigned(t *testing.T) { + t.Run("should provide a function to set entrypoints values", func(t *testing.T) { + assigned := make(domain.TargetEntrypointsAssigned) - t.Run("should remove empty map keys when updating entrypoints", func(t *testing.T) { - target := fixture.Target() - deployment := fixture.Deployment() - appService := deployment.Config().NewService("app", "") - http := appService.AddHttpEntrypoint(deployment.Config(), 80, domain.HttpEntrypointOptions{}) - tcp := appService.AddTCPEntrypoint(5432) + assigned.Set("app", domain.Production, "http", 3000) + assigned.Set("app", domain.Production, "tcp", 3001) + assigned.Set("app", domain.Staging, "http", 3002) - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{appService}) - assert.DeepEqual(t, domain.TargetEntrypoints{ - deployment.ID().AppID(): { + assert.DeepEqual(t, domain.TargetEntrypointsAssigned{ + "app": { domain.Production: { - http.Name(): monad.None[domain.Port](), - tcp.Name(): monad.None[domain.Port](), + "http": 3000, + "tcp": 3001, + }, + domain.Staging: { + "http": 3002, }, }, - }, target.CustomEntrypoints()) - - target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{}) - - assert.DeepEqual(t, domain.TargetEntrypoints{}, target.CustomEntrypoints()) - }) - - t.Run("should not be removed if no cleanup request has been set", func(t *testing.T) { - target := fixture.Target() - - err := target.Delete(true) - - assert.ErrorIs(t, domain.ErrTargetCleanupNeeded, err) - }) - - t.Run("should not be removed if target resources have not been cleaned up", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) // No application is using it - - err := target.Delete(false) - - assert.ErrorIs(t, domain.ErrTargetCleanupNeeded, err) - }) - - t.Run("could be removed if resources have been cleaned up", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - assert.Nil(t, target.RequestCleanup(false, "uid")) - - err := target.Delete(true) - - assert.Nil(t, err) - assert.EventIs[domain.TargetDeleted](t, &target, 3) - }) -} - -func Test_TargetEvents(t *testing.T) { - t.Run("TargetStateChanged should provide a function to check for configuration changes", func(t *testing.T) { - target := fixture.Target() - target.Configured(target.CurrentVersion(), nil, nil) - - evt := assert.EventIs[domain.TargetStateChanged](t, &target, 1) - assert.False(t, evt.WentToConfiguringState()) - - assert.Nil(t, target.Reconfigure()) - - evt = assert.EventIs[domain.TargetStateChanged](t, &target, 2) - assert.True(t, evt.WentToConfiguringState()) + }, assigned) }) } diff --git a/internal/deployment/fixture/deployment.go b/internal/deployment/fixture/deployment.go index f498c0c1..581f0140 100644 --- a/internal/deployment/fixture/deployment.go +++ b/internal/deployment/fixture/deployment.go @@ -44,6 +44,12 @@ func FromApp(app domain.App) DeploymentOptionBuilder { } } +func WithSourceData(source domain.SourceData) DeploymentOptionBuilder { + return func(o *deploymentOption) { + o.source = source + } +} + func WithDeploymentRequestedBy(uid auth.UserID) DeploymentOptionBuilder { return func(o *deploymentOption) { o.uid = uid diff --git a/internal/deployment/fixture/target.go b/internal/deployment/fixture/target.go index 66c1f74e..fb3076b8 100644 --- a/internal/deployment/fixture/target.go +++ b/internal/deployment/fixture/target.go @@ -15,7 +15,6 @@ import ( type ( targetOption struct { name string - url domain.Url provider domain.ProviderConfig uid auth.UserID } @@ -26,7 +25,6 @@ type ( func Target(options ...TargetOptionBuilder) domain.Target { opts := targetOption{ name: id.New[string](), - url: must.Panic(domain.UrlFrom("http://" + id.New[string]() + ".com")), provider: ProviderConfig(), uid: id.New[auth.UserID](), } @@ -36,7 +34,6 @@ func Target(options ...TargetOptionBuilder) domain.Target { } return must.Panic(domain.NewTarget(opts.name, - domain.NewTargetUrlRequirement(opts.url, true), domain.NewProviderConfigRequirement(opts.provider, true), opts.uid)) } @@ -53,12 +50,6 @@ func WithTargetCreatedBy(uid auth.UserID) TargetOptionBuilder { } } -func WithTargetUrl(url domain.Url) TargetOptionBuilder { - return func(opts *targetOption) { - opts.url = url - } -} - func WithProviderConfig(config domain.ProviderConfig) TargetOptionBuilder { return func(opts *targetOption) { opts.provider = config @@ -67,6 +58,7 @@ func WithProviderConfig(config domain.ProviderConfig) TargetOptionBuilder { type ( providerConfig struct { + Kind_ string Data string Fingerprint_ string } @@ -74,9 +66,10 @@ type ( ProviderConfigBuilder func(*providerConfig) ) -func ProviderConfig(options ...ProviderConfigBuilder) domain.ProviderConfig { +func ProviderConfig(options ...ProviderConfigBuilder) (result domain.ProviderConfig) { config := providerConfig{ Data: id.New[string](), + Kind_: id.New[string](), Fingerprint_: id.New[string](), } @@ -84,7 +77,18 @@ func ProviderConfig(options ...ProviderConfigBuilder) domain.ProviderConfig { o(&config) } - return config + result = config + + // Just ignore the panic due to the multiple registration of same kind + defer func() { + _ = recover() + }() + + domain.ProviderConfigTypes.Register(config, func(s string) (domain.ProviderConfig, error) { + return storage.UnmarshalJSON[providerConfig](s) + }) + + return } func WithFingerprint(fingerprint string) ProviderConfigBuilder { @@ -93,7 +97,19 @@ func WithFingerprint(fingerprint string) ProviderConfigBuilder { } } -func (d providerConfig) Kind() string { return "test" } +func WithKind(kind string) ProviderConfigBuilder { + return func(config *providerConfig) { + config.Kind_ = kind + } +} + +func WithData(data string) ProviderConfigBuilder { + return func(config *providerConfig) { + config.Data = data + } +} + +func (d providerConfig) Kind() string { return d.Kind_ } func (d providerConfig) Fingerprint() string { return d.Fingerprint_ } func (d providerConfig) String() string { return d.Fingerprint_ } func (d providerConfig) Value() (driver.Value, error) { return storage.ValueJSON(d) } @@ -101,9 +117,3 @@ func (d providerConfig) Value() (driver.Value, error) { return storage.ValueJSON func (d providerConfig) Equals(other domain.ProviderConfig) bool { return d == other } - -func init() { - domain.ProviderConfigTypes.Register(providerConfig{}, func(s string) (domain.ProviderConfig, error) { - return storage.UnmarshalJSON[providerConfig](s) - }) -} diff --git a/internal/deployment/infra/artifact/local_artifact_manager.go b/internal/deployment/infra/artifact/local_artifact_manager.go index fa88c12b..5a2f150f 100644 --- a/internal/deployment/infra/artifact/local_artifact_manager.go +++ b/internal/deployment/infra/artifact/local_artifact_manager.go @@ -55,16 +55,16 @@ func NewLocal(options LocalOptions, logger log.Logger) domain.ArtifactManager { func (a *localArtifactManager) PrepareBuild( ctx context.Context, - depl domain.Deployment, + deployment domain.Deployment, ) (domain.DeploymentContext, error) { - logfile, err := ostools.OpenAppend(a.LogPath(ctx, depl)) + logFile, err := ostools.OpenAppend(a.LogPath(ctx, deployment)) if err != nil { a.logger.Error(err) return domain.DeploymentContext{}, ErrArtifactOpenLoggerFailed } - logger := newLogger(logfile) + logger := newLogger(logFile) defer func() { if err == nil { @@ -76,7 +76,7 @@ func (a *localArtifactManager) PrepareBuild( logger.Close() // And close the logger right now }() - buildDirectory, err := a.deploymentPath(depl) + buildDirectory, err := a.deploymentPath(deployment) if err != nil { return domain.DeploymentContext{}, err diff --git a/internal/deployment/infra/provider/docker/client.go b/internal/deployment/infra/provider/docker/client.go index 6951f8c6..3158fe18 100644 --- a/internal/deployment/infra/provider/docker/client.go +++ b/internal/deployment/infra/provider/docker/client.go @@ -12,10 +12,10 @@ import ( "github.com/docker/cli/cli/flags" "github.com/docker/compose/v2/pkg/api" "github.com/docker/compose/v2/pkg/compose" - "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/volume" dclient "github.com/docker/docker/client" ) @@ -142,7 +142,7 @@ func (c *client) RemoveResources(ctx context.Context, criteria filters.Args) err } // List and remove all networks - networks, err := c.api.NetworkList(ctx, types.NetworkListOptions{ + networks, err := c.api.NetworkList(ctx, network.ListOptions{ Filters: criteria, }) diff --git a/internal/deployment/infra/provider/docker/deployment.go b/internal/deployment/infra/provider/docker/deployment.go index 09f7c07e..3f53ad10 100644 --- a/internal/deployment/infra/provider/docker/deployment.go +++ b/internal/deployment/infra/provider/docker/deployment.go @@ -18,23 +18,35 @@ import ( "golang.org/x/exp/maps" ) -type deploymentProjectBuilder struct { - sourceDir string - composePath string - networkName string - services domain.Services - project *types.Project - config domain.DeploymentConfig - logger domain.DeploymentLogger - labels types.Labels - isDefaultSubdomainAvailable bool - routersByPort map[string]domain.Router -} +type ( + DeploymentProjectBuilder interface { + Build(context.Context) (*types.Project, domain.Services, error) + } + + deploymentProjectBuilder struct { + exposedManually bool + sourceDir string + composePath string + networkName string + services domain.Services + project *types.Project + config domain.DeploymentConfig + logger domain.DeploymentLogger + labels types.Labels + isDefaultSubdomainAvailable bool + routersByPort map[string]domain.Router + } +) -func newDeploymentProjectBuilder(ctx domain.DeploymentContext, depl domain.Deployment) *deploymentProjectBuilder { - config := depl.Config() +func newDeploymentProjectBuilder( + ctx domain.DeploymentContext, + deployment domain.Deployment, + target domain.Target, +) DeploymentProjectBuilder { + config := deployment.Config() return &deploymentProjectBuilder{ + exposedManually: target.IsManual(), isDefaultSubdomainAvailable: true, sourceDir: ctx.BuildDirectory(), config: config, @@ -42,7 +54,7 @@ func newDeploymentProjectBuilder(ctx domain.DeploymentContext, depl domain.Deplo logger: ctx.Logger(), routersByPort: make(map[string]domain.Router), labels: types.Labels{ - AppLabel: string(depl.ID().AppID()), + AppLabel: string(deployment.ID().AppID()), TargetLabel: string(config.Target()), EnvironmentLabel: string(config.Environment()), }, @@ -111,11 +123,14 @@ func (b *deploymentProjectBuilder) findComposeFile() error { func (b *deploymentProjectBuilder) loadProject(ctx context.Context) error { b.logger.Stepf("reading project from %s", b.composePath) - opts, err := cli.NewProjectOptions([]string{b.composePath}, + loaders := []cli.ProjectOptionsFn{ cli.WithName(b.config.ProjectName()), cli.WithNormalization(true), cli.WithProfiles([]string{string(b.config.Environment())}), - cli.WithLoadOptions(func(o *loader.Options) { + } + + if !b.exposedManually { + loaders = append(loaders, cli.WithLoadOptions(func(o *loader.Options) { o.Interpolate = &interpolation.Options{ TypeCastMapping: map[tree.Path]interpolation.Cast{ "services.*.ports.[]": func(value string) (any, error) { @@ -123,8 +138,10 @@ func (b *deploymentProjectBuilder) loadProject(ctx context.Context) error { }, }, } - }), - ) + })) + } + + opts, err := cli.NewProjectOptions([]string{b.composePath}, loaders...) if err != nil { b.logger.Error(err) @@ -155,7 +172,7 @@ func (b *deploymentProjectBuilder) transform() { } // Let's transform the project to expose needed services - // Here ServiceNames sort the services by alphabetical order + // Here ServiceNames sort the services by alphabetical order so we don't have to for _, name := range b.project.ServiceNames() { serviceDefinition := b.project.Services[name] service := b.config.NewService(serviceDefinition.Name, serviceDefinition.Image) @@ -195,8 +212,8 @@ func (b *deploymentProjectBuilder) transform() { } } - // No ports mapped, nothing to do - if len(serviceDefinition.Ports) == 0 { + // No ports mapped or manual target, nothing to do + if b.exposedManually || len(serviceDefinition.Ports) == 0 { b.project.Services[serviceName] = serviceDefinition b.services = append(b.services, service) continue @@ -276,6 +293,9 @@ func (b *deploymentProjectBuilder) transform() { } // Append the public seelf network to the project + if b.exposedManually { + return + } if b.project.Networks == nil { b.project.Networks = types.Networks{} diff --git a/internal/deployment/infra/provider/docker/provider.go b/internal/deployment/infra/provider/docker/provider.go index 22378143..aeffbd82 100644 --- a/internal/deployment/infra/provider/docker/provider.go +++ b/internal/deployment/infra/provider/docker/provider.go @@ -78,13 +78,14 @@ func New(logger log.Logger, configuration ...DockerOptions) Docker { } // Use the given compose service and cli instead of creating new ones. Used for testing. -func WithDockerAndCompose(cli command.Cli, composeService api.Service) DockerOptions { +func WithTestConfig(cli command.Cli, composeService api.Service, sshConfigPath string) DockerOptions { return func(d *docker) { d.client = &client{ cli: cli, api: cli.Client(), compose: composeService, } + d.sshConfig = ssh.NewFileConfigurator(sshConfigPath) } } @@ -172,6 +173,14 @@ func (d *docker) Setup(ctx context.Context, target domain.Target) (domain.Target defer client.Close() + if target.IsManual() { + return nil, client.compose.Down(ctx, targetProjectName(target.ID()), api.DownOptions{ + RemoveOrphans: true, + Images: "all", + Volumes: true, + }) + } + project, assigned, err := newProxyProjectBuilder(client, target).Build(ctx) if err != nil { @@ -224,7 +233,7 @@ func (d *docker) Expose(ctx context.Context, target domain.Target, container str func (d *docker) Deploy( ctx context.Context, deploymentCtx domain.DeploymentContext, - depl domain.Deployment, + deployment domain.Deployment, target domain.Target, registries []domain.Registry, ) (domain.Services, error) { @@ -244,7 +253,7 @@ func (d *docker) Deploy( logger.Infof("using custom registries: %s", strings.Join(client.registries, ", ")) } - project, services, err := newDeploymentProjectBuilder(deploymentCtx, depl).Build(ctx) + project, services, err := newDeploymentProjectBuilder(deploymentCtx, deployment, target).Build(ctx) if err != nil { return nil, err @@ -267,19 +276,21 @@ func (d *docker) Deploy( return nil, ErrComposeFailed } - if target.Url().UseSSL() { - logger.Infof("you may have to wait for certificates to be generated before your app is available") - } + if url, isManagedBySeelf := target.Url().TryGet(); isManagedBySeelf { + if url.UseSSL() { + logger.Infof("you may have to wait for certificates to be generated before your app is available") + } - if len(services.CustomEntrypoints()) > 0 { - logger.Infof("this deployment uses custom entrypoints. If this is the first time, you may have to wait a few seconds for the target to find available ports and expose them appropriately") + if len(services.CustomEntrypoints()) > 0 { + logger.Infof("this deployment uses custom entrypoints. If this is the first time, you may have to wait a few seconds for the target to find available ports and expose them appropriately") + } } prunedCount, err := client.PruneImages(ctx, filters.NewArgs( filters.Arg("dangling", "true"), - filters.Arg("label", AppLabel+"="+string(depl.ID().AppID())), + filters.Arg("label", AppLabel+"="+string(deployment.ID().AppID())), filters.Arg("label", TargetLabel+"="+string(target.ID())), - filters.Arg("label", EnvironmentLabel+"="+string(depl.Config().Environment())), + filters.Arg("label", EnvironmentLabel+"="+string(deployment.Config().Environment())), )) if err != nil { diff --git a/internal/deployment/infra/provider/docker/provider_test.go b/internal/deployment/infra/provider/docker/provider_test.go index 43e0fd2d..e5555576 100644 --- a/internal/deployment/infra/provider/docker/provider_test.go +++ b/internal/deployment/infra/provider/docker/provider_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "os" + "path/filepath" "slices" "strconv" "strings" @@ -12,6 +13,7 @@ import ( "github.com/YuukanOO/seelf/cmd/config" "github.com/YuukanOO/seelf/internal/deployment/domain" + "github.com/YuukanOO/seelf/internal/deployment/fixture" "github.com/YuukanOO/seelf/internal/deployment/infra/artifact" "github.com/YuukanOO/seelf/internal/deployment/infra/provider/docker" "github.com/YuukanOO/seelf/internal/deployment/infra/source/raw" @@ -44,7 +46,7 @@ func Test_Provider(t *testing.T) { os.RemoveAll(opts.DataDir()) }) - return docker.New(logger, docker.WithDockerAndCompose(mock, mock)), mock + return docker.New(logger, docker.WithTestConfig(mock, mock, filepath.Join(opts.DataDir(), "config"))), mock } t.Run("should be able to prepare a docker provider config from a raw payload", func(t *testing.T) { @@ -213,315 +215,568 @@ wSD0v0RcmkITP1ZR0AAAAYcHF1ZXJuYUBMdWNreUh5ZHJvLmxvY2FsAQID } }) - t.Run("should setup a new non-ssl target without custom entrypoints", func(t *testing.T) { - target := createTarget("http://docker.localhost") - targetIdLower := strings.ToLower(string(target.ID())) - - provider, mock := arrange(config.Default(config.WithTestDefaults())) - - assigned, err := provider.Setup(context.Background(), target) - - assert.Nil(t, err) - assert.DeepEqual(t, domain.TargetEntrypointsAssigned{}, assigned) - assert.HasLength(t, 1, mock.ups) - assert.DeepEqual(t, &types.Project{ - Name: "seelf-internal-" + targetIdLower, - Services: types.Services{ - "proxy": { - Name: "proxy", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), - }, - Image: "traefik:v2.11", - Restart: types.RestartPolicyUnlessStopped, - Command: types.ShellCommand{ - "--entrypoints.http.address=:80", - "--providers.docker", - fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", - docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), - fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), - "--providers.docker.network=seelf-gateway-" + targetIdLower, - }, - Ports: []types.ServicePortConfig{ - {Target: 80, Published: "80"}, - }, - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, - }, - CustomLabels: types.Labels{ - api.ProjectLabel: "seelf-internal-" + targetIdLower, - api.ServiceLabel: "proxy", - api.VersionLabel: api.ComposeVersion, - api.ConfigFilesLabel: "", - api.OneoffLabel: "False", + t.Run("should correctly setup needed stuff on a target", func(t *testing.T) { + t.Run("with automatic proxy, no-ssl, no custom entrypoints", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) + targetIdLower := strings.ToLower(string(target.ID())) + provider, mock := arrange(config.Default(config.WithTestDefaults())) + + assigned, err := provider.Setup(context.Background(), target) + + assert.Nil(t, err) + assert.DeepEqual(t, domain.TargetEntrypointsAssigned{}, assigned) + assert.HasLength(t, 1, mock.ups) + assert.DeepEqual(t, &types.Project{ + Name: "seelf-internal-" + targetIdLower, + Services: types.Services{ + "proxy": { + Name: "proxy", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, + Image: "traefik:v2.11", + Restart: types.RestartPolicyUnlessStopped, + Command: types.ShellCommand{ + "--entrypoints.http.address=:80", + "--providers.docker", + fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", + docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), + fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), + "--providers.docker.network=seelf-gateway-" + targetIdLower, + }, + Ports: []types.ServicePortConfig{ + {Target: 80, Published: "80"}, + }, + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, + }, + CustomLabels: types.Labels{ + api.ProjectLabel: "seelf-internal-" + targetIdLower, + api.ServiceLabel: "proxy", + api.VersionLabel: api.ComposeVersion, + api.ConfigFilesLabel: "", + api.OneoffLabel: "False", + }, }, }, - }, - Networks: types.Networks{ - "default": types.NetworkConfig{ - Name: "seelf-gateway-" + targetIdLower, - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + Networks: types.Networks{ + "default": types.NetworkConfig{ + Name: "seelf-gateway-" + targetIdLower, + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, }, }, - }, - }, mock.ups[0].project) - }) - - t.Run("should setup a new ssl target without custom entrypoints", func(t *testing.T) { - target := createTarget("https://docker.localhost") - targetIdLower := strings.ToLower(string(target.ID())) - - provider, mock := arrange(config.Default(config.WithTestDefaults())) - - assigned, err := provider.Setup(context.Background(), target) + }, mock.ups[0].project) + }) - assert.Nil(t, err) - assert.DeepEqual(t, domain.TargetEntrypointsAssigned{}, assigned) - assert.HasLength(t, 1, mock.ups) - assert.DeepEqual(t, &types.Project{ - Name: "seelf-internal-" + targetIdLower, - Services: types.Services{ - "proxy": { - Name: "proxy", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), - }, - Image: "traefik:v2.11", - Restart: types.RestartPolicyUnlessStopped, - Command: types.ShellCommand{ - fmt.Sprintf("--certificatesresolvers.%s.acme.storage=/letsencrypt/acme.json", "seelf-resolver-"+targetIdLower), - fmt.Sprintf("--certificatesresolvers.%s.acme.tlschallenge=true", "seelf-resolver-"+targetIdLower), - "--entrypoints.http.address=:443", - "--entrypoints.http.http.tls.certresolver=seelf-resolver-" + targetIdLower, - "--entrypoints.insecure.address=:80", - "--entrypoints.insecure.http.redirections.entryPoint.scheme=https", - "--entrypoints.insecure.http.redirections.entryPoint.to=http", - "--providers.docker", - fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", - docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), - fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), - "--providers.docker.network=seelf-gateway-" + targetIdLower, - }, - Ports: []types.ServicePortConfig{ - {Target: 80, Published: "80"}, - {Target: 443, Published: "443"}, - }, - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, - {Type: types.VolumeTypeVolume, Source: "letsencrypt", Target: "/letsencrypt"}, - }, - CustomLabels: types.Labels{ - api.ProjectLabel: "seelf-internal-" + targetIdLower, - api.ServiceLabel: "proxy", - api.VersionLabel: api.ComposeVersion, - api.ConfigFilesLabel: "", - api.OneoffLabel: "False", + t.Run("with automatic proxy, ssl, no custom entrypoints", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("https://docker.localhost")), true))) + targetIdLower := strings.ToLower(string(target.ID())) + provider, mock := arrange(config.Default(config.WithTestDefaults())) + + assigned, err := provider.Setup(context.Background(), target) + + assert.Nil(t, err) + assert.DeepEqual(t, domain.TargetEntrypointsAssigned{}, assigned) + assert.HasLength(t, 1, mock.ups) + assert.DeepEqual(t, &types.Project{ + Name: "seelf-internal-" + targetIdLower, + Services: types.Services{ + "proxy": { + Name: "proxy", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, + Image: "traefik:v2.11", + Restart: types.RestartPolicyUnlessStopped, + Command: types.ShellCommand{ + fmt.Sprintf("--certificatesresolvers.%s.acme.storage=/letsencrypt/acme.json", "seelf-resolver-"+targetIdLower), + fmt.Sprintf("--certificatesresolvers.%s.acme.tlschallenge=true", "seelf-resolver-"+targetIdLower), + "--entrypoints.http.address=:443", + "--entrypoints.http.http.tls.certresolver=seelf-resolver-" + targetIdLower, + "--entrypoints.insecure.address=:80", + "--entrypoints.insecure.http.redirections.entryPoint.scheme=https", + "--entrypoints.insecure.http.redirections.entryPoint.to=http", + "--providers.docker", + fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", + docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), + fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), + "--providers.docker.network=seelf-gateway-" + targetIdLower, + }, + Ports: []types.ServicePortConfig{ + {Target: 80, Published: "80"}, + {Target: 443, Published: "443"}, + }, + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, + {Type: types.VolumeTypeVolume, Source: "letsencrypt", Target: "/letsencrypt"}, + }, + CustomLabels: types.Labels{ + api.ProjectLabel: "seelf-internal-" + targetIdLower, + api.ServiceLabel: "proxy", + api.VersionLabel: api.ComposeVersion, + api.ConfigFilesLabel: "", + api.OneoffLabel: "False", + }, }, }, - }, - Networks: types.Networks{ - "default": types.NetworkConfig{ - Name: "seelf-gateway-" + targetIdLower, - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + Networks: types.Networks{ + "default": types.NetworkConfig{ + Name: "seelf-gateway-" + targetIdLower, + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, }, }, - }, - Volumes: types.Volumes{ - "letsencrypt": types.VolumeConfig{ - Name: "seelf-internal-" + targetIdLower + "_letsencrypt", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + Volumes: types.Volumes{ + "letsencrypt": types.VolumeConfig{ + Name: "seelf-internal-" + targetIdLower + "_letsencrypt", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, }, }, - }, - }, mock.ups[0].project) - }) - - t.Run("should setup a target with custom entrypoints by finding available ports", func(t *testing.T) { - target := createTarget("http://docker.localhost") - targetIdLower := strings.ToLower(string(target.ID())) - depl := createDeployment(target.ID(), "") - - service := depl.Config().NewService("app", "") - tcp := service.AddTCPEntrypoint(5432) - udp := service.AddUDPEntrypoint(5433) - - target.ExposeEntrypoints(depl.ID().AppID(), depl.Config().Environment(), domain.Services{service}) - - provider, mock := arrange(config.Default(config.WithTestDefaults())) - - assigned, err := provider.Setup(context.Background(), target) - - assert.Nil(t, err) - assert.HasLength(t, 2, mock.ups) - assert.HasLength(t, 1, mock.downs) - - tcpPort := assigned[depl.ID().AppID()][depl.Config().Environment()][tcp.Name()] - udpPort := assigned[depl.ID().AppID()][depl.Config().Environment()][udp.Name()] - - assert.NotEqual(t, 0, tcpPort) - assert.NotEqual(t, 0, udpPort) + }, mock.ups[0].project) + }) - assert.DeepEqual(t, &types.Project{ - Name: "seelf-internal-" + targetIdLower, - Services: types.Services{ - "proxy": { - Name: "proxy", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + t.Run("with automatic proxy, custom entrypoints", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) + targetIdLower := strings.ToLower(string(target.ID())) + app := fixture.App(fixture.WithAppName("my-app"), fixture.WithEnvironmentConfig( + domain.NewEnvironmentConfig(target.ID()), + domain.NewEnvironmentConfig(target.ID()), + )) + deployment := fixture.Deployment(fixture.FromApp(app)) + service := deployment.Config().NewService("app", "") + tcp := service.AddTCPEntrypoint(5432) + udp := service.AddUDPEntrypoint(5433) + target.ExposeEntrypoints(deployment.Config().AppID(), deployment.Config().Environment(), domain.Services{service}) + provider, mock := arrange(config.Default(config.WithTestDefaults())) + + assigned, err := provider.Setup(context.Background(), target) + + assert.Nil(t, err) + assert.HasLength(t, 2, mock.ups, "should have launch two projects since it has to find available ports") + assert.HasLength(t, 1, mock.downs) + tcpPort := assigned[deployment.ID().AppID()][deployment.Config().Environment()][tcp.Name()] + udpPort := assigned[deployment.ID().AppID()][deployment.Config().Environment()][udp.Name()] + + assert.NotZero(t, tcpPort) + assert.NotZero(t, udpPort) + + assert.DeepEqual(t, &types.Project{ + Name: "seelf-internal-" + targetIdLower, + Services: types.Services{ + "proxy": { + Name: "proxy", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, + Image: "traefik:v2.11", + Restart: types.RestartPolicyUnlessStopped, + Command: types.ShellCommand{ + "--entrypoints.http.address=:80", + fmt.Sprintf("--entrypoints.%s.address=:%d/tcp", tcp.Name(), tcpPort), + fmt.Sprintf("--entrypoints.%s.address=:%d/udp", udp.Name(), udpPort), + "--providers.docker", + fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", + docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), + fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), + "--providers.docker.network=seelf-gateway-" + targetIdLower, + }, + Ports: sortedPorts([]types.ServicePortConfig{ + {Target: 80, Published: "80"}, + {Target: uint32(tcpPort), Published: strconv.FormatUint(uint64(tcpPort), 10), Protocol: "tcp"}, + {Target: uint32(udpPort), Published: strconv.FormatUint(uint64(udpPort), 10), Protocol: "udp"}, + }), + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, + }, + CustomLabels: types.Labels{ + api.ProjectLabel: "seelf-internal-" + targetIdLower, + api.ServiceLabel: "proxy", + api.VersionLabel: api.ComposeVersion, + api.ConfigFilesLabel: "", + api.OneoffLabel: "False", + }, }, - Image: "traefik:v2.11", - Restart: types.RestartPolicyUnlessStopped, - Command: types.ShellCommand{ - "--entrypoints.http.address=:80", - fmt.Sprintf("--entrypoints.%s.address=:%d/tcp", tcp.Name(), tcpPort), - fmt.Sprintf("--entrypoints.%s.address=:%d/udp", udp.Name(), udpPort), - "--providers.docker", - fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", - docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), - fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), - "--providers.docker.network=seelf-gateway-" + targetIdLower, + }, + Networks: types.Networks{ + "default": types.NetworkConfig{ + Name: "seelf-gateway-" + targetIdLower, + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, }, - Ports: sortedPorts([]types.ServicePortConfig{ - {Target: 80, Published: "80"}, - {Target: uint32(tcpPort), Published: strconv.FormatUint(uint64(tcpPort), 10), Protocol: "tcp"}, - {Target: uint32(udpPort), Published: strconv.FormatUint(uint64(udpPort), 10), Protocol: "udp"}, - }), - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, + }, + }, mock.ups[1].project) + }) + + t.Run("with automatic proxy, custom entrypoints and already determined ports", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) + targetIdLower := strings.ToLower(string(target.ID())) + app := fixture.App(fixture.WithAppName("my-app"), fixture.WithEnvironmentConfig( + domain.NewEnvironmentConfig(target.ID()), + domain.NewEnvironmentConfig(target.ID()), + )) + deployment := fixture.Deployment(fixture.FromApp(app)) + service := deployment.Config().NewService("app", "") + tcp := service.AddTCPEntrypoint(5432) + udp := service.AddUDPEntrypoint(5433) + target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{service}) + target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ + deployment.ID().AppID(): { + deployment.Config().Environment(): { + tcp.Name(): 5432, + udp.Name(): 5433, }, - CustomLabels: types.Labels{ - api.ProjectLabel: "seelf-internal-" + targetIdLower, - api.ServiceLabel: "proxy", - api.VersionLabel: api.ComposeVersion, - api.ConfigFilesLabel: "", - api.OneoffLabel: "False", + }, + }, nil) + newTcp := service.AddTCPEntrypoint(5434) + newUdp := service.AddUDPEntrypoint(5435) + target.ExposeEntrypoints(deployment.ID().AppID(), deployment.Config().Environment(), domain.Services{service}) + provider, mock := arrange(config.Default(config.WithTestDefaults())) + + assigned, err := provider.Setup(context.Background(), target) + + assert.Nil(t, err) + assert.HasLength(t, 2, mock.ups) + assert.HasLength(t, 1, mock.downs) + assert.Equal(t, 2, len(assigned[deployment.ID().AppID()][deployment.Config().Environment()])) + + tcpPort := assigned[deployment.ID().AppID()][deployment.Config().Environment()][newTcp.Name()] + udpPort := assigned[deployment.ID().AppID()][deployment.Config().Environment()][newUdp.Name()] + + assert.NotZero(t, tcpPort) + assert.NotZero(t, udpPort) + + assert.DeepEqual(t, &types.Project{ + Name: "seelf-internal-" + targetIdLower, + Services: types.Services{ + "proxy": { + Name: "proxy", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, + Image: "traefik:v2.11", + Restart: types.RestartPolicyUnlessStopped, + Command: types.ShellCommand{ + "--entrypoints.http.address=:80", + fmt.Sprintf("--entrypoints.%s.address=:5432/tcp", tcp.Name()), + fmt.Sprintf("--entrypoints.%s.address=:5433/udp", udp.Name()), + fmt.Sprintf("--entrypoints.%s.address=:%d/tcp", newTcp.Name(), tcpPort), + fmt.Sprintf("--entrypoints.%s.address=:%d/udp", newUdp.Name(), udpPort), + "--providers.docker", + fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", + docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), + fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), + "--providers.docker.network=seelf-gateway-" + targetIdLower, + }, + Ports: sortedPorts([]types.ServicePortConfig{ + {Target: 80, Published: "80"}, + {Target: 5432, Published: "5432", Protocol: "tcp"}, + {Target: 5433, Published: "5433", Protocol: "udp"}, + {Target: uint32(tcpPort), Published: strconv.FormatUint(uint64(tcpPort), 10), Protocol: "tcp"}, + {Target: uint32(udpPort), Published: strconv.FormatUint(uint64(udpPort), 10), Protocol: "udp"}, + }), + Volumes: []types.ServiceVolumeConfig{ + {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, + }, + CustomLabels: types.Labels{ + api.ProjectLabel: "seelf-internal-" + targetIdLower, + api.ServiceLabel: "proxy", + api.VersionLabel: api.ComposeVersion, + api.ConfigFilesLabel: "", + api.OneoffLabel: "False", + }, }, }, - }, - Networks: types.Networks{ - "default": types.NetworkConfig{ - Name: "seelf-gateway-" + targetIdLower, - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + Networks: types.Networks{ + "default": types.NetworkConfig{ + Name: "seelf-gateway-" + targetIdLower, + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + }, }, }, - }, - }, mock.ups[1].project) - }) - - t.Run("should setup a target with custom entrypoints by using provided ports if any", func(t *testing.T) { - target := createTarget("http://docker.localhost") - targetIdLower := strings.ToLower(string(target.ID())) - depl := createDeployment(target.ID(), "") - - service := depl.Config().NewService("app", "") - tcp := service.AddTCPEntrypoint(5432) - udp := service.AddUDPEntrypoint(5433) + }, mock.ups[1].project) + }) - target.ExposeEntrypoints(depl.ID().AppID(), depl.Config().Environment(), domain.Services{service}) - target.Configured(target.CurrentVersion(), domain.TargetEntrypointsAssigned{ - depl.ID().AppID(): { - depl.Config().Environment(): { - tcp.Name(): 5432, - udp.Name(): 5433, - }, - }, - }, nil) + t.Run("with manual target, should not deploy the proxy and remove existing one", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + provider, mock := arrange(config.Default(config.WithTestDefaults())) - newTcp := service.AddTCPEntrypoint(5434) - newUdp := service.AddUDPEntrypoint(5435) - target.ExposeEntrypoints(depl.ID().AppID(), depl.Config().Environment(), domain.Services{service}) + assigned, err := provider.Setup(context.Background(), target) - provider, mock := arrange(config.Default(config.WithTestDefaults())) + assert.Nil(t, err) + assert.True(t, assigned == nil) + assert.HasLength(t, 0, mock.ups) + assert.HasLength(t, 1, mock.downs) + assert.Equal(t, "seelf-internal-"+strings.ToLower(string(target.ID())), mock.downs[0].projectName) + }) + }) - assigned, err := provider.Setup(context.Background(), target) + t.Run("should be able to process a deployment", func(t *testing.T) { + t.Run("should returns an error if no valid compose file was found", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + deployment := fixture.Deployment() + opts := config.Default(config.WithTestDefaults()) + artifactManager := artifact.NewLocal(opts, logger) + ctx, err := artifactManager.PrepareBuild(context.Background(), deployment) + assert.Nil(t, err) + defer ctx.Logger().Close() + provider, _ := arrange(opts) - assert.Nil(t, err) - assert.HasLength(t, 2, mock.ups) - assert.HasLength(t, 1, mock.downs) - assert.Equal(t, 2, len(assigned[depl.ID().AppID()][depl.Config().Environment()])) + _, err = provider.Deploy(context.Background(), ctx, deployment, target, nil) - tcpPort := assigned[depl.ID().AppID()][depl.Config().Environment()][newTcp.Name()] - udpPort := assigned[depl.ID().AppID()][depl.Config().Environment()][newUdp.Name()] + assert.ErrorIs(t, docker.ErrOpenComposeFileFailed, err) + }) - assert.NotEqual(t, 0, tcpPort) - assert.NotEqual(t, 0, udpPort) + t.Run("should correctly transform the compose file if the target is configured as automatically exposing services", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + assert.Nil(t, target.ExposeServicesAutomatically(domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true))) + productionConfig := domain.NewEnvironmentConfig(target.ID()) + productionConfig.HasEnvironmentVariables(domain.ServicesEnv{ + "app": domain.EnvVars{ + "DSN": "postgres://prodapp:passprod@db/app?sslmode=disable", + }, + "db": domain.EnvVars{ + "POSTGRES_USER": "prodapp", + "POSTGRES_PASSWORD": "passprod", + }, + }) + app := fixture.App( + fixture.WithAppName("my-app"), + fixture.WithEnvironmentConfig( + productionConfig, + domain.NewEnvironmentConfig(target.ID()), + ), + ) + deployment := fixture.Deployment( + fixture.FromApp(app), + fixture.ForEnvironment(domain.Production), + fixture.WithSourceData(raw.Data(`services: + sidecar: + image: traefik/whoami + profiles: + - production + app: + restart: unless-stopped + build: . + environment: + - DSN=postgres://app:apppa55word@db/app?sslmode=disable + depends_on: + - db + ports: + - "8080:8080" + - "8081:8081/udp" + - "8082:8082" + stagingonly: + image: traefik/whoami + ports: + - "8888:80" + profiles: + - staging + db: + restart: unless-stopped + image: postgres:14-alpine + volumes: + - dbdata:/var/lib/postgresql/data + environment: + - POSTGRES_USER=app + - POSTGRES_PASSWORD=apppa55word + ports: + - "5432:5432/tcp" +volumes: + dbdata:`)), + ) + appIdLower := strings.ToLower(string(app.ID())) + + // Prepare the build + opts := config.Default(config.WithTestDefaults()) + artifactManager := artifact.NewLocal(opts, logger) + deploymentContext, err := artifactManager.PrepareBuild(context.Background(), deployment) + assert.Nil(t, err) + assert.Nil(t, raw.New().Fetch(context.Background(), deploymentContext, deployment)) + defer deploymentContext.Logger().Close() + provider, mock := arrange(opts) + + services, err := provider.Deploy(context.Background(), deploymentContext, deployment, target, nil) + + assert.Nil(t, err) + assert.HasLength(t, 1, mock.ups) + assert.HasLength(t, 3, services) + + assert.Equal(t, "app", services[0].Name()) + assert.Equal(t, "db", services[1].Name()) + assert.Equal(t, "sidecar", services[2].Name()) + + entrypoints := services.Entrypoints() + assert.HasLength(t, 4, entrypoints) + assert.Equal(t, 8080, entrypoints[0].Port()) + assert.Equal(t, "http", entrypoints[0].Router()) + assert.Equal(t, string(deployment.Config().AppName()), entrypoints[0].Subdomain().Get("")) + assert.Equal(t, 8081, entrypoints[1].Port()) + assert.Equal(t, "udp", entrypoints[1].Router()) + assert.Equal(t, 8082, entrypoints[2].Port()) + assert.Equal(t, "http", entrypoints[2].Router()) + assert.Equal(t, string(deployment.Config().AppName()), entrypoints[2].Subdomain().Get("")) + assert.Equal(t, 5432, entrypoints[3].Port()) + assert.Equal(t, "tcp", entrypoints[3].Router()) + + project := mock.ups[0].project + expectedProjectName := fmt.Sprintf("%s-%s-%s", deployment.Config().AppName(), deployment.Config().Environment(), appIdLower) + expectedGatewayNetworkName := "seelf-gateway-" + strings.ToLower(string(target.ID())) + assert.Equal(t, expectedProjectName, project.Name) + assert.Equal(t, 3, len(project.Services)) + + for _, service := range project.Services { + switch service.Name { + case "sidecar": + assert.Equal(t, "traefik/whoami", service.Image) + assert.HasLength(t, 0, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{}, service.Environment) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, service.Labels) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + }, service.Networks) + case "app": + httpEntrypointName := string(entrypoints[0].Name()) + udpEntrypointName := string(entrypoints[1].Name()) + customHttpEntrypointName := string(entrypoints[2].Name()) + dsn := deployment.Config().EnvironmentVariablesFor("app").MustGet()["DSN"] + + assert.Equal(t, fmt.Sprintf("%s-%s/app:%s", deployment.Config().AppName(), appIdLower, deployment.Config().Environment()), service.Image) + assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + docker.SubdomainLabel: string(deployment.Config().AppName()), + fmt.Sprintf("traefik.http.routers.%s.entrypoints", httpEntrypointName): "http", + fmt.Sprintf("traefik.http.routers.%s.service", httpEntrypointName): httpEntrypointName, + fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", httpEntrypointName): "8080", + docker.CustomEntrypointsLabel: "true", + fmt.Sprintf("traefik.udp.routers.%s.entrypoints", udpEntrypointName): udpEntrypointName, + fmt.Sprintf("traefik.udp.routers.%s.service", udpEntrypointName): udpEntrypointName, + fmt.Sprintf("traefik.udp.services.%s.loadbalancer.server.port", udpEntrypointName): "8081", + fmt.Sprintf("traefik.http.routers.%s.entrypoints", customHttpEntrypointName): customHttpEntrypointName, + fmt.Sprintf("traefik.http.routers.%s.service", customHttpEntrypointName): customHttpEntrypointName, + fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", customHttpEntrypointName): "8082", + }, service.Labels) + + assert.HasLength(t, 0, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{ + "DSN": &dsn, + }, service.Environment) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + expectedGatewayNetworkName: nil, + }, service.Networks) + case "db": + entrypointName := string(entrypoints[3].Name()) + postgresUser := deployment.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_USER"] + postgresPassword := deployment.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_PASSWORD"] + + assert.Equal(t, "postgres:14-alpine", service.Image) + assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + fmt.Sprintf("traefik.tcp.routers.%s.rule", entrypointName): "HostSNI(`*`)", + docker.CustomEntrypointsLabel: "true", + fmt.Sprintf("traefik.tcp.routers.%s.entrypoints", entrypointName): entrypointName, + fmt.Sprintf("traefik.tcp.routers.%s.service", entrypointName): entrypointName, + fmt.Sprintf("traefik.tcp.services.%s.loadbalancer.server.port", entrypointName): "5432", + }, service.Labels) + assert.HasLength(t, 0, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{ + "POSTGRES_USER": &postgresUser, + "POSTGRES_PASSWORD": &postgresPassword, + }, service.Environment) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + expectedGatewayNetworkName: nil, + }, service.Networks) + assert.DeepEqual(t, []types.ServiceVolumeConfig{ + { + Type: types.VolumeTypeVolume, + Source: "dbdata", + Target: "/var/lib/postgresql/data", + Volume: &types.ServiceVolumeVolume{}, + }, + }, service.Volumes) + default: + t.Fatalf("unexpected service %s", service.Name) + } + } - assert.DeepEqual(t, &types.Project{ - Name: "seelf-internal-" + targetIdLower, - Services: types.Services{ - "proxy": { - Name: "proxy", + assert.DeepEqual(t, types.Networks{ + "default": { + Name: expectedProjectName + "_default", Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), - }, - Image: "traefik:v2.11", - Restart: types.RestartPolicyUnlessStopped, - Command: types.ShellCommand{ - "--entrypoints.http.address=:80", - fmt.Sprintf("--entrypoints.%s.address=:5432/tcp", tcp.Name()), - fmt.Sprintf("--entrypoints.%s.address=:5433/udp", udp.Name()), - fmt.Sprintf("--entrypoints.%s.address=:%d/tcp", newTcp.Name(), tcpPort), - fmt.Sprintf("--entrypoints.%s.address=:%d/udp", newUdp.Name(), udpPort), - "--providers.docker", - fmt.Sprintf("--providers.docker.constraints=(Label(`%s`, `%s`) && (Label(`%s`, `true`) || LabelRegex(`%s`, `.+`))) || Label(`%s`, `true`)", - docker.TargetLabel, target.ID(), docker.CustomEntrypointsLabel, docker.SubdomainLabel, docker.ExposedLabel), - fmt.Sprintf("--providers.docker.defaultrule=Host(`{{ index .Labels %s}}.docker.localhost`)", fmt.Sprintf(`"%s"`, docker.SubdomainLabel)), - "--providers.docker.network=seelf-gateway-" + targetIdLower, - }, - Ports: sortedPorts([]types.ServicePortConfig{ - {Target: 80, Published: "80"}, - {Target: 5432, Published: "5432", Protocol: "tcp"}, - {Target: 5433, Published: "5433", Protocol: "udp"}, - {Target: uint32(tcpPort), Published: strconv.FormatUint(uint64(tcpPort), 10), Protocol: "tcp"}, - {Target: uint32(udpPort), Published: strconv.FormatUint(uint64(udpPort), 10), Protocol: "udp"}, - }), - Volumes: []types.ServiceVolumeConfig{ - {Type: types.VolumeTypeBind, Source: "/var/run/docker.sock", Target: "/var/run/docker.sock"}, - }, - CustomLabels: types.Labels{ - api.ProjectLabel: "seelf-internal-" + targetIdLower, - api.ServiceLabel: "proxy", - api.VersionLabel: api.ComposeVersion, - api.ConfigFilesLabel: "", - api.OneoffLabel: "False", + docker.TargetLabel: string(target.ID()), + docker.AppLabel: string(deployment.Config().AppID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), }, }, - }, - Networks: types.Networks{ - "default": types.NetworkConfig{ - Name: "seelf-gateway-" + targetIdLower, + expectedGatewayNetworkName: { + Name: expectedGatewayNetworkName, + External: true, + }, + }, project.Networks) + assert.DeepEqual(t, types.Volumes{ + "dbdata": { + Name: expectedProjectName + "_dbdata", Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), + docker.TargetLabel: string(target.ID()), + docker.AppLabel: string(deployment.Config().AppID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), }, }, - }, - }, mock.ups[1].project) - }) - - t.Run("should returns an error if no valid compose file was found for a deployment", func(t *testing.T) { - target := createTarget("http://docker.localhost") - depl := createDeployment(target.ID(), "") - opts := config.Default(config.WithTestDefaults()) - artifactManager := artifact.NewLocal(opts, logger) - - ctx, err := artifactManager.PrepareBuild(context.Background(), depl) - assert.Nil(t, err) - defer ctx.Logger().Close() - - provider, _ := arrange(opts) - - _, err = provider.Deploy(context.Background(), ctx, depl, target, nil) - - assert.ErrorIs(t, docker.ErrOpenComposeFileFailed, err) - }) + }, project.Volumes) + + assert.DeepEqual(t, filters.NewArgs( + filters.Arg("dangling", "true"), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.AppLabel, deployment.ID().AppID())), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.TargetLabel, target.ID())), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.EnvironmentLabel, deployment.Config().Environment())), + ), mock.pruneFilters) + }) - t.Run("should expose services from a compose file", func(t *testing.T) { - target := createTarget("http://docker.localhost") - depl := createDeployment(target.ID(), `services: + t.Run("should correctly transform the compose file if the target is configured with a manual proxy", func(t *testing.T) { + target := fixture.Target(fixture.WithProviderConfig(docker.Data{})) + productionConfig := domain.NewEnvironmentConfig(target.ID()) + productionConfig.HasEnvironmentVariables(domain.ServicesEnv{ + "app": domain.EnvVars{ + "DSN": "postgres://prodapp:passprod@db/app?sslmode=disable", + }, + "db": domain.EnvVars{ + "POSTGRES_USER": "prodapp", + "POSTGRES_PASSWORD": "passprod", + }, + }) + app := fixture.App( + fixture.WithAppName("my-app"), + fixture.WithEnvironmentConfig( + productionConfig, + domain.NewEnvironmentConfig(target.ID()), + ), + ) + deployment := fixture.Deployment( + fixture.FromApp(app), + fixture.ForEnvironment(domain.Production), + fixture.WithSourceData(raw.Data(`services: sidecar: image: traefik/whoami profiles: @@ -554,197 +809,156 @@ wSD0v0RcmkITP1ZR0AAAAYcHF1ZXJuYUBMdWNreUh5ZHJvLmxvY2FsAQID ports: - "5432:5432/tcp" volumes: - dbdata:`) - appIdLower := strings.ToLower(string(depl.ID().AppID())) - - // Prepare the build - opts := config.Default(config.WithTestDefaults()) - artifactManager := artifact.NewLocal(opts, logger) - ctx, err := artifactManager.PrepareBuild(context.Background(), depl) - assert.Nil(t, err) - assert.Nil(t, raw.New().Fetch(context.Background(), ctx, depl)) - defer ctx.Logger().Close() - - provider, mock := arrange(opts) - - services, err := provider.Deploy(context.Background(), ctx, depl, target, nil) - - assert.Nil(t, err) - assert.HasLength(t, 1, mock.ups) - assert.HasLength(t, 3, services) - - assert.Equal(t, "app", services[0].Name()) - assert.Equal(t, "db", services[1].Name()) - assert.Equal(t, "sidecar", services[2].Name()) - - entrypoints := services.Entrypoints() - assert.HasLength(t, 4, entrypoints) - assert.Equal(t, 8080, entrypoints[0].Port()) - assert.Equal(t, "http", entrypoints[0].Router()) - assert.Equal(t, string(depl.Config().AppName()), entrypoints[0].Subdomain().Get("")) - assert.Equal(t, 8081, entrypoints[1].Port()) - assert.Equal(t, "udp", entrypoints[1].Router()) - assert.Equal(t, 8082, entrypoints[2].Port()) - assert.Equal(t, "http", entrypoints[2].Router()) - assert.Equal(t, string(depl.Config().AppName()), entrypoints[2].Subdomain().Get("")) - assert.Equal(t, 5432, entrypoints[3].Port()) - assert.Equal(t, "tcp", entrypoints[3].Router()) - - project := mock.ups[0].project - expectedProjectName := fmt.Sprintf("%s-%s-%s", depl.Config().AppName(), depl.Config().Environment(), appIdLower) - expectedGatewayNetworkName := "seelf-gateway-" + strings.ToLower(string(target.ID())) - assert.Equal(t, expectedProjectName, project.Name) - assert.Equal(t, 3, len(project.Services)) - - for _, service := range project.Services { - switch service.Name { - case "sidecar": - assert.Equal(t, "traefik/whoami", service.Image) - assert.HasLength(t, 0, service.Ports) - assert.DeepEqual(t, types.MappingWithEquals{}, service.Environment) - assert.DeepEqual(t, types.Labels{ - docker.AppLabel: string(depl.ID().AppID()), - docker.TargetLabel: string(target.ID()), - docker.EnvironmentLabel: string(depl.Config().Environment()), - }, service.Labels) - assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ - "default": nil, - }, service.Networks) - case "app": - httpEntrypointName := string(entrypoints[0].Name()) - udpEntrypointName := string(entrypoints[1].Name()) - customHttpEntrypointName := string(entrypoints[2].Name()) - dsn := depl.Config().EnvironmentVariablesFor("app").MustGet()["DSN"] - - assert.Equal(t, fmt.Sprintf("%s-%s/app:%s", depl.Config().AppName(), appIdLower, depl.Config().Environment()), service.Image) - assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) - assert.DeepEqual(t, types.Labels{ - docker.AppLabel: string(depl.ID().AppID()), - docker.TargetLabel: string(target.ID()), - docker.EnvironmentLabel: string(depl.Config().Environment()), - docker.SubdomainLabel: string(depl.Config().AppName()), - fmt.Sprintf("traefik.http.routers.%s.entrypoints", httpEntrypointName): "http", - fmt.Sprintf("traefik.http.routers.%s.service", httpEntrypointName): httpEntrypointName, - fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", httpEntrypointName): "8080", - docker.CustomEntrypointsLabel: "true", - fmt.Sprintf("traefik.udp.routers.%s.entrypoints", udpEntrypointName): udpEntrypointName, - fmt.Sprintf("traefik.udp.routers.%s.service", udpEntrypointName): udpEntrypointName, - fmt.Sprintf("traefik.udp.services.%s.loadbalancer.server.port", udpEntrypointName): "8081", - fmt.Sprintf("traefik.http.routers.%s.entrypoints", customHttpEntrypointName): customHttpEntrypointName, - fmt.Sprintf("traefik.http.routers.%s.service", customHttpEntrypointName): customHttpEntrypointName, - fmt.Sprintf("traefik.http.services.%s.loadbalancer.server.port", customHttpEntrypointName): "8082", - }, service.Labels) - - assert.HasLength(t, 0, service.Ports) - assert.DeepEqual(t, types.MappingWithEquals{ - "DSN": &dsn, - }, service.Environment) - assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ - "default": nil, - expectedGatewayNetworkName: nil, - }, service.Networks) - case "db": - entrypointName := string(entrypoints[3].Name()) - postgresUser := depl.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_USER"] - postgresPassword := depl.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_PASSWORD"] - - assert.Equal(t, "postgres:14-alpine", service.Image) - assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) - assert.DeepEqual(t, types.Labels{ - docker.AppLabel: string(depl.ID().AppID()), - docker.TargetLabel: string(target.ID()), - docker.EnvironmentLabel: string(depl.Config().Environment()), - fmt.Sprintf("traefik.tcp.routers.%s.rule", entrypointName): "HostSNI(`*`)", - docker.CustomEntrypointsLabel: "true", - fmt.Sprintf("traefik.tcp.routers.%s.entrypoints", entrypointName): entrypointName, - fmt.Sprintf("traefik.tcp.routers.%s.service", entrypointName): entrypointName, - fmt.Sprintf("traefik.tcp.services.%s.loadbalancer.server.port", entrypointName): "5432", - }, service.Labels) - assert.HasLength(t, 0, service.Ports) - assert.DeepEqual(t, types.MappingWithEquals{ - "POSTGRES_USER": &postgresUser, - "POSTGRES_PASSWORD": &postgresPassword, - }, service.Environment) - assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ - "default": nil, - expectedGatewayNetworkName: nil, - }, service.Networks) - assert.DeepEqual(t, []types.ServiceVolumeConfig{ - { - Type: types.VolumeTypeVolume, - Source: "dbdata", - Target: "/var/lib/postgresql/data", - Volume: &types.ServiceVolumeVolume{}, - }, - }, service.Volumes) - default: - t.Fatalf("unexpected service %s", service.Name) + dbdata:`)), + ) + appIdLower := strings.ToLower(string(app.ID())) + + // Prepare the build + opts := config.Default(config.WithTestDefaults()) + artifactManager := artifact.NewLocal(opts, logger) + deploymentContext, err := artifactManager.PrepareBuild(context.Background(), deployment) + assert.Nil(t, err) + assert.Nil(t, raw.New().Fetch(context.Background(), deploymentContext, deployment)) + defer deploymentContext.Logger().Close() + provider, mock := arrange(opts) + + services, err := provider.Deploy(context.Background(), deploymentContext, deployment, target, nil) + + assert.Nil(t, err) + assert.HasLength(t, 1, mock.ups) + assert.HasLength(t, 3, services) + + assert.Equal(t, "app", services[0].Name()) + assert.Equal(t, "db", services[1].Name()) + assert.Equal(t, "sidecar", services[2].Name()) + + assert.HasLength(t, 0, services.Entrypoints()) + + project := mock.ups[0].project + expectedProjectName := fmt.Sprintf("%s-%s-%s", deployment.Config().AppName(), deployment.Config().Environment(), appIdLower) + assert.Equal(t, expectedProjectName, project.Name) + assert.Equal(t, 3, len(project.Services)) + + for _, service := range project.Services { + switch service.Name { + case "sidecar": + assert.Equal(t, "traefik/whoami", service.Image) + assert.HasLength(t, 0, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{}, service.Environment) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, service.Labels) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + }, service.Networks) + case "app": + dsn := deployment.Config().EnvironmentVariablesFor("app").MustGet()["DSN"] + + assert.Equal(t, fmt.Sprintf("%s-%s/app:%s", deployment.Config().AppName(), appIdLower, deployment.Config().Environment()), service.Image) + assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, service.Labels) + + assert.DeepEqual(t, []types.ServicePortConfig{ + { + Protocol: "tcp", + Mode: "ingress", + Target: 8080, + Published: "8080", + }, + { + Protocol: "udp", + Mode: "ingress", + Target: 8081, + Published: "8081", + }, + { + Protocol: "tcp", + Mode: "ingress", + Target: 8082, + Published: "8082", + }, + }, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{ + "DSN": &dsn, + }, service.Environment) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + }, service.Networks) + case "db": + postgresUser := deployment.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_USER"] + postgresPassword := deployment.Config().EnvironmentVariablesFor("db").MustGet()["POSTGRES_PASSWORD"] + + assert.Equal(t, "postgres:14-alpine", service.Image) + assert.Equal(t, types.RestartPolicyUnlessStopped, service.Restart) + assert.DeepEqual(t, types.Labels{ + docker.AppLabel: string(deployment.ID().AppID()), + docker.TargetLabel: string(target.ID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, service.Labels) + assert.DeepEqual(t, []types.ServicePortConfig{ + { + Protocol: "tcp", + Mode: "ingress", + Target: 5432, + Published: "5432", + }, + }, service.Ports) + assert.DeepEqual(t, types.MappingWithEquals{ + "POSTGRES_USER": &postgresUser, + "POSTGRES_PASSWORD": &postgresPassword, + }, service.Environment) + assert.DeepEqual(t, map[string]*types.ServiceNetworkConfig{ + "default": nil, + }, service.Networks) + assert.DeepEqual(t, []types.ServiceVolumeConfig{ + { + Type: types.VolumeTypeVolume, + Source: "dbdata", + Target: "/var/lib/postgresql/data", + Volume: &types.ServiceVolumeVolume{}, + }, + }, service.Volumes) + default: + t.Fatalf("unexpected service %s", service.Name) + } } - } - assert.DeepEqual(t, types.Networks{ - "default": { - Name: expectedProjectName + "_default", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), - docker.AppLabel: string(depl.Config().AppID()), - docker.EnvironmentLabel: string(depl.Config().Environment()), + assert.DeepEqual(t, types.Networks{ + "default": { + Name: expectedProjectName + "_default", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + docker.AppLabel: string(deployment.Config().AppID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, }, - }, - expectedGatewayNetworkName: { - Name: expectedGatewayNetworkName, - External: true, - }, - }, project.Networks) - assert.DeepEqual(t, types.Volumes{ - "dbdata": { - Name: expectedProjectName + "_dbdata", - Labels: types.Labels{ - docker.TargetLabel: string(target.ID()), - docker.AppLabel: string(depl.Config().AppID()), - docker.EnvironmentLabel: string(depl.Config().Environment()), + }, project.Networks) + assert.DeepEqual(t, types.Volumes{ + "dbdata": { + Name: expectedProjectName + "_dbdata", + Labels: types.Labels{ + docker.TargetLabel: string(target.ID()), + docker.AppLabel: string(deployment.Config().AppID()), + docker.EnvironmentLabel: string(deployment.Config().Environment()), + }, }, - }, - }, project.Volumes) - - assert.DeepEqual(t, filters.NewArgs( - filters.Arg("dangling", "true"), - filters.Arg("label", fmt.Sprintf("%s=%s", docker.AppLabel, depl.ID().AppID())), - filters.Arg("label", fmt.Sprintf("%s=%s", docker.TargetLabel, target.ID())), - filters.Arg("label", fmt.Sprintf("%s=%s", docker.EnvironmentLabel, depl.Config().Environment())), - ), mock.pruneFilters) - }) -} - -func createTarget(url string) domain.Target { - return must.Panic(domain.NewTarget( - "a target", - domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom(url)), true), - domain.NewProviderConfigRequirement(docker.Data{}, true), - "uid", - )) -} - -func createDeployment(target domain.TargetID, data string) domain.Deployment { - productionConfig := domain.NewEnvironmentConfig(target) - productionConfig.HasEnvironmentVariables(domain.ServicesEnv{ - "app": domain.EnvVars{ - "DSN": "postgres://prodapp:passprod@db/app?sslmode=disable", - }, - "db": domain.EnvVars{ - "POSTGRES_USER": "prodapp", - "POSTGRES_PASSWORD": "passprod", - }, + }, project.Volumes) + + assert.DeepEqual(t, filters.NewArgs( + filters.Arg("dangling", "true"), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.AppLabel, deployment.ID().AppID())), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.TargetLabel, target.ID())), + filters.Arg("label", fmt.Sprintf("%s=%s", docker.EnvironmentLabel, deployment.Config().Environment())), + ), mock.pruneFilters) + }) }) - app := must.Panic(domain.NewApp( - "my-app", - domain.NewEnvironmentConfigRequirement(productionConfig, true, true), - domain.NewEnvironmentConfigRequirement(domain.NewEnvironmentConfig(target), true, true), - "uid", - )) - - return must.Panic(app.NewDeployment(1, raw.Data(data), domain.Production, "uid")) } func sortedPorts(ports []types.ServicePortConfig) []types.ServicePortConfig { @@ -845,19 +1059,3 @@ func (d *dockerMockCli) ImagesPrune(_ context.Context, criteria filters.Args) (i d.parent.pruneFilters = criteria return image.PruneReport{}, nil } - -// func (d *dockerMockService) ContainerList(context.Context, container.ListOptions) ([]dockertypes.Container, error) { -// return nil, nil -// } - -// func (d *dockerMockService) VolumeList(context.Context, volume.ListOptions) (volume.ListResponse, error) { -// return volume.ListResponse{}, nil -// } - -// func (d *dockerMockService) NetworkList(context.Context, dockertypes.NetworkListOptions) ([]dockertypes.NetworkResource, error) { -// return nil, nil -// } - -// func (d *dockerMockService) ImageList(context.Context, image.ListOptions) ([]image.Summary, error) { -// return nil, nil -// } diff --git a/internal/deployment/infra/provider/docker/proxy.go b/internal/deployment/infra/provider/docker/proxy.go index 32e8364f..2a1321ed 100644 --- a/internal/deployment/infra/provider/docker/proxy.go +++ b/internal/deployment/infra/provider/docker/proxy.go @@ -17,6 +17,10 @@ const ( ) type ( + ProxyProjectBuilder interface { + Build(context.Context) (*types.Project, domain.TargetEntrypointsAssigned, error) + } + // Builder used to create a compose project with everything needed to deploy // the proxy used to expose application entrypoints. // It will handle the assignment of new entrypoints ports if needed. @@ -44,23 +48,24 @@ type ( } ) -func newProxyProjectBuilder(client *client, target domain.Target) *proxyProjectBuilder { +func newProxyProjectBuilder(client *client, target domain.Target) ProxyProjectBuilder { id := target.ID() - idLower := strings.ToLower(string(id)) + idLower := domain.TargetID(strings.ToLower(string(id))) + url := target.Url().MustGet() b := &proxyProjectBuilder{ client: client, target: string(id), - host: target.Url().Host(), + host: url.Host(), entrypoints: target.CustomEntrypoints(), assigned: make(domain.TargetEntrypointsAssigned), - networkName: targetPublicNetworkName(target.ID()), - projectName: "seelf-internal-" + idLower, + networkName: targetPublicNetworkName(idLower), + projectName: targetProjectName(idLower), labels: types.Labels{TargetLabel: string(id)}, } - if target.Url().UseSSL() { - b.certResolverName = "seelf-resolver-" + idLower + if url.UseSSL() { + b.certResolverName = "seelf-resolver-" + string(idLower) } return b @@ -291,3 +296,8 @@ func ServicePortSortFunc(a, b types.ServicePortConfig) int { func targetPublicNetworkName(id domain.TargetID) string { return "seelf-gateway-" + strings.ToLower(string(id)) } + +// Retrieve the project name of a specific target +func targetProjectName(id domain.TargetID) string { + return "seelf-internal-" + strings.ToLower(string(id)) +} diff --git a/internal/deployment/infra/provider/facade.go b/internal/deployment/infra/provider/facade.go index d9b91d8b..8df1501b 100644 --- a/internal/deployment/infra/provider/facade.go +++ b/internal/deployment/infra/provider/facade.go @@ -33,14 +33,14 @@ func (f *facade) Prepare(ctx context.Context, payload any, existing ...domain.Pr return nil, domain.ErrNoValidProviderFound } -func (f *facade) Deploy(ctx context.Context, info domain.DeploymentContext, depl domain.Deployment, target domain.Target, registries []domain.Registry) (domain.Services, error) { +func (f *facade) Deploy(ctx context.Context, info domain.DeploymentContext, deployment domain.Deployment, target domain.Target, registries []domain.Registry) (domain.Services, error) { provider, err := f.providerForTarget(target) if err != nil { return nil, err } - return provider.Deploy(ctx, info, depl, target, registries) + return provider.Deploy(ctx, info, deployment, target, registries) } func (f *facade) Setup(ctx context.Context, target domain.Target) (domain.TargetEntrypointsAssigned, error) { diff --git a/internal/deployment/infra/provider/facade_test.go b/internal/deployment/infra/provider/facade_test.go index 12efd61f..014bc92e 100644 --- a/internal/deployment/infra/provider/facade_test.go +++ b/internal/deployment/infra/provider/facade_test.go @@ -5,19 +5,12 @@ import ( "testing" "github.com/YuukanOO/seelf/internal/deployment/domain" + "github.com/YuukanOO/seelf/internal/deployment/fixture" "github.com/YuukanOO/seelf/internal/deployment/infra/provider" "github.com/YuukanOO/seelf/pkg/assert" - "github.com/YuukanOO/seelf/pkg/must" ) func Test_Facade(t *testing.T) { - env := domain.NewEnvironmentConfigRequirement(domain.NewEnvironmentConfig("1"), true, true) - app := must.Panic(domain.NewApp("app", env, env, "uid")) - depl := must.Panic(app.NewDeployment(1, dummySourceData{}, domain.Production, "uid")) - url := domain.NewTargetUrlRequirement(must.Panic(domain.UrlFrom("http://docker.localhost")), true) - providerConfig := domain.NewProviderConfigRequirement(dummyProviderConfig{}, true) - target := must.Panic(domain.NewTarget("target", url, providerConfig, "uid")) - t.Run("should return an error if no provider can handle the payload", func(t *testing.T) { sut := provider.NewFacade() @@ -28,6 +21,8 @@ func Test_Facade(t *testing.T) { t.Run("should return an error if no provider can handle the deployment", func(t *testing.T) { sut := provider.NewFacade() + target := fixture.Target() + depl := fixture.Deployment() _, err := sut.Deploy(context.Background(), domain.DeploymentContext{}, depl, target, nil) @@ -36,6 +31,7 @@ func Test_Facade(t *testing.T) { t.Run("should return an error if no provider can configure the target", func(t *testing.T) { sut := provider.NewFacade() + target := fixture.Target() _, err := sut.Setup(context.Background(), target) @@ -44,6 +40,7 @@ func Test_Facade(t *testing.T) { t.Run("should return an error if no provider can unconfigure the target", func(t *testing.T) { sut := provider.NewFacade() + target := fixture.Target() err := sut.RemoveConfiguration(context.Background(), target) @@ -52,6 +49,7 @@ func Test_Facade(t *testing.T) { t.Run("should return an error if no provider can cleanup the target", func(t *testing.T) { sut := provider.NewFacade() + target := fixture.Target() err := sut.CleanupTarget(context.Background(), target, domain.CleanupStrategyDefault) @@ -60,23 +58,11 @@ func Test_Facade(t *testing.T) { t.Run("should return an error if no provider can cleanup the app", func(t *testing.T) { sut := provider.NewFacade() + app := fixture.App() + target := fixture.Target() err := sut.Cleanup(context.Background(), app.ID(), target, domain.Production, domain.CleanupStrategyDefault) assert.ErrorIs(t, domain.ErrNoValidProviderFound, err) }) } - -type ( - dummyProviderConfig struct { - domain.ProviderConfig - } - - dummySourceData struct { - domain.SourceData - } -) - -func (d dummyProviderConfig) Kind() string { return "dummy" } -func (d dummySourceData) Kind() string { return "dummy" } -func (d dummySourceData) NeedVersionControl() bool { return false } diff --git a/internal/deployment/infra/sqlite/migrations/1706004450_add_target.up.sql b/internal/deployment/infra/sqlite/migrations/1706004450_add_target.up.sql index 924c88e6..2f588c5b 100644 --- a/internal/deployment/infra/sqlite/migrations/1706004450_add_target.up.sql +++ b/internal/deployment/infra/sqlite/migrations/1706004450_add_target.up.sql @@ -73,6 +73,7 @@ SELECT FROM targets; -- Rename the old apps table since it will be recreated with proper NOT NULL columns +-- I should have used a temporary table ALTER TABLE apps RENAME TO tmp_apps; -- Create the new apps table with proper columns @@ -146,6 +147,7 @@ SELECT FROM tmp_apps; -- Do the same for deployments +-- I should have used a temporary table ALTER TABLE deployments RENAME TO tmp_deployments; CREATE TABLE deployments ( diff --git a/internal/deployment/infra/sqlite/migrations/1726473707_target_url_optional.up.sql b/internal/deployment/infra/sqlite/migrations/1726473707_target_url_optional.up.sql new file mode 100644 index 00000000..629a32f9 --- /dev/null +++ b/internal/deployment/infra/sqlite/migrations/1726473707_target_url_optional.up.sql @@ -0,0 +1,55 @@ +-- since we cannot change the url nullable property easily, we have to do this steps +CREATE TEMPORARY TABLE tmp_targets AS +SELECT * +FROM targets; + +CREATE TEMPORARY TABLE tmp_apps AS +SELECT * +FROM apps; + +CREATE TEMPORARY TABLE tmp_deployments AS +SELECT * +FROM deployments; + +DELETE FROM deployments; +DELETE FROM apps; +DROP TABLE targets; + +CREATE TABLE targets ( + id TEXT NOT NULL, + name TEXT NOT NULL, + url TEXT NULL, + provider_kind TEXT NOT NULL, + provider_fingerprint TEXT NOT NULL, + provider TEXT NOT NULL, + state_status INTEGER NOT NULL, + state_version DATETIME NOT NULL, + state_errcode TEXT NULL, + state_last_ready_version DATETIME NULL, + cleanup_requested_at DATETIME NULL, + cleanup_requested_by TEXT NULL, + created_at DATETIME NOT NULL, + created_by TEXT NOT NULL, + entrypoints TEXT NOT NULL DEFAULT '{}', + + CONSTRAINT pk_targets PRIMARY KEY(id), + CONSTRAINT fk_targets_created_by FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT unique_targets_url UNIQUE(url), -- unique url among all targets + CONSTRAINT unique_targets_provider_fingerprint UNIQUE(provider_fingerprint) -- unique provider fingerprint +); + +INSERT INTO targets +SELECT * +FROM tmp_targets; + +INSERT INTO apps +SELECT * +FROM tmp_apps; + +INSERT INTO deployments +SELECT * +FROM tmp_deployments; + +DROP TABLE tmp_targets; +DROP TABLE tmp_apps; +DROP TABLE tmp_deployments; \ No newline at end of file diff --git a/internal/deployment/infra/sqlite/targets.go b/internal/deployment/infra/sqlite/targets.go index b3b01afc..5eee1ae2 100644 --- a/internal/deployment/infra/sqlite/targets.go +++ b/internal/deployment/infra/sqlite/targets.go @@ -99,7 +99,6 @@ func (s *targetsStore) Write(c context.Context, targets ...*domain.Target) error Insert("targets", builder.Values{ "id": evt.ID, "name": evt.Name, - "url": evt.Url, "provider_kind": evt.Provider.Kind(), "provider_fingerprint": evt.Provider.Fingerprint(), "provider": evt.Provider, @@ -136,6 +135,13 @@ func (s *targetsStore) Write(c context.Context, targets ...*domain.Target) error }). F("WHERE id = ?", evt.ID). Exec(s.db, ctx) + case domain.TargetUrlRemoved: + return builder. + Update("targets", builder.Values{ + "url": nil, + }). + F("WHERE id = ?", evt.ID). + Exec(s.db, ctx) case domain.TargetProviderChanged: return builder. Update("targets", builder.Values{ diff --git a/pkg/storage/sqlite/builder/builder.go b/pkg/storage/sqlite/builder/builder.go index b7bf74c4..1d291ef9 100644 --- a/pkg/storage/sqlite/builder/builder.go +++ b/pkg/storage/sqlite/builder/builder.go @@ -161,7 +161,7 @@ func (q *queryBuilder[T]) All( defer rows.Close() - var results []T + results := make([]T, 0) // Instantiates needed stuff for data loaders mappings := make([]keysMapping, len(loaders))