-
+
{l.translate('datatable.no_data')}
diff --git a/cmd/serve/front/src/components/registry-card.svelte b/cmd/serve/front/src/components/registry-card.svelte
new file mode 100644
index 00000000..89dfeccc
--- /dev/null
+++ b/cmd/serve/front/src/components/registry-card.svelte
@@ -0,0 +1,30 @@
+
+
+
+ {data.name}
+ {data.url}
+
+
+
diff --git a/cmd/serve/front/src/lib/fetcher/cache.ts b/cmd/serve/front/src/lib/fetcher/cache.ts
index c8ba2f29..ecc77a28 100644
--- a/cmd/serve/front/src/lib/fetcher/cache.ts
+++ b/cmd/serve/front/src/lib/fetcher/cache.ts
@@ -133,7 +133,12 @@ export default class CacheFetchService implements FetchService {
const result = await api(method, url, body, options);
// Invalidate all the cache entries that matches the base key
- const keys = [url, ...(options?.invalidate ?? [])];
+ const keys = options?.invalidate ?? [];
+
+ if (!options?.skipUrlInvalidate) {
+ keys.push(url);
+ }
+
await this._cache.invalidate(...keys);
return result;
diff --git a/cmd/serve/front/src/lib/fetcher/index.ts b/cmd/serve/front/src/lib/fetcher/index.ts
index a2a845ba..92b35ae3 100644
--- a/cmd/serve/front/src/lib/fetcher/index.ts
+++ b/cmd/serve/front/src/lib/fetcher/index.ts
@@ -31,6 +31,11 @@ export type MutateOptions = Pick & {
* url but you can specify other urls to invalidate here.
*/
invalidate?: string[];
+ /**
+ * By default the url at which the mutate is done is invalidated but you
+ * can override this behavior by setting this flag to true.
+ */
+ skipUrlInvalidate?: boolean;
};
export type QueryResult = {
diff --git a/cmd/serve/front/src/lib/localization/en.ts b/cmd/serve/front/src/lib/localization/en.ts
index 3293eb2c..e890a111 100644
--- a/cmd/serve/front/src/lib/localization/en.ts
+++ b/cmd/serve/front/src/lib/localization/en.ts
@@ -1,4 +1,5 @@
import type { Locale, Translations } from '$lib/localization';
+import routes from '$lib/path';
const translations = {
// Authentication
@@ -10,8 +11,7 @@ const translations = {
'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.title': 'Looks like you have no application yet. Start by',
- 'app.blankslate.cta': 'creating one!',
+ 'app.blankslate': `Looks like you have no application yet. Applications represents services you want to deploy on your infrastructure. Start by creating one! `,
'app.new': 'New application',
'app.edit': 'Edit application',
'app.delete': 'Delete application',
@@ -38,9 +38,7 @@ This action is IRREVERSIBLE and will DELETE ALL DATA associated with this applic
'app.vcs.help':
'If not under version control, you will still be able to manually deploy your application.',
'app.vcs.token': 'Access token',
- 'app.vcs.token.help.instructions':
- 'Token used to fetch the provided repository. Generally known as Personal Access Token , you can find some instructions for',
- 'app.vcs.token.help.leave_empty': ', leave empty if the repository is public.',
+ 'app.vcs.token.help': `Token used to fetch the provided repository. Generally known as Personal Access Token , you can find some instructions for Github and Gitlab , leave empty if the repository is public.`,
'app.environment.production': 'Production settings',
'app.environment.staging': 'Staging settings',
'app.environment.target': 'Deploy target',
@@ -76,8 +74,7 @@ This action is IRREVERSIBLE and will DELETE ALL DATA associated with this applic
'target.configuring': 'Target configuration in progress',
'target.configuring.description':
'Needed infrastructure is being deployed on the target, please wait.',
- 'target.blankslate.title': 'Looks like you have no target yet. Start by',
- 'target.blankslate.cta': 'creating one!',
+ '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.url.help':
@@ -127,6 +124,25 @@ You may reconsider and try to make the target reachable before deleting it.`,
'deployment.command.delete_target': 'Target removal',
'deployment.command.configure_target': 'Target configuration',
'deployment.command.deploy': 'Application deployment',
+ // Registries
+ 'registry.new': 'New registry',
+ 'registry.blankslate': `Looks like you have no custom registry yet. If some of your images are hosted on private registries , configure them here to make them available.`,
+ 'registry.not_found':
+ "Looks like the registry you're looking for does not exist. Head back to the",
+ 'registry.not_found.cta': 'registries page',
+ 'registry.delete': 'Delete registry',
+ 'registry.delete.confirm': (name: string) =>
+ `Are you sure you want to delete the registry ${name}?`,
+ 'registry.delete.failed': 'Deletion failed',
+ 'registry.general': 'General settings',
+ 'registry.url.help':
+ 'Url of the registry. For a private Docker Hub registry, use the url https://index.docker.io/v1/
.',
+ 'registry.authentication': 'Authentication',
+ 'registry.auth': 'Need authentication',
+ 'registry.auth.help': 'Does the registry require authentication?',
+ 'registry.username': 'Username',
+ 'registry.password': 'Password',
+ 'registry.name.help': 'The name is being used only for display, it can be anything you want.',
// Account
'profile.my': 'my profile',
'profile.logout': 'log out',
@@ -155,8 +171,10 @@ You may reconsider and try to make the target reachable before deleting it.`,
'deployment.promote.confirm': (number: number) =>
`The deployment #${number} will be promoted to the production environment. Do you confirm this action?`,
'deployment.promote.failed': 'Promote failed',
- 'deployment.blankslate.title': 'No deployment to show. Go ahead and',
- 'deployment.blankslate.cta': 'create the first one!',
+ 'deployment.blankslate': (app: string) =>
+ `No deployment to show. Go ahead and create the first one! `,
'deployment.environment': 'Environment',
'deployment.payload': 'Payload',
'deployment.payload.copy_curl': 'Copy cURL command',
@@ -204,6 +222,9 @@ You may reconsider and try to make the target reachable before deleting it.`,
'breadcrumb.target.new': 'New target',
'breadcrumb.target.settings': (name: string) => `${name} settings`,
'breadcrumb.jobs': 'Jobs',
+ 'breadcrumb.registries': 'Registries',
+ 'breadcrumb.registry.new': 'New registry',
+ 'breadcrumb.registry.settings': (name: string) => `${name} settings`,
'breadcrumb.profile': 'Profile',
'breadcrumb.not_found': 'Not found',
// Footer
diff --git a/cmd/serve/front/src/lib/localization/fr.ts b/cmd/serve/front/src/lib/localization/fr.ts
index 3ed1b37e..85058e3d 100644
--- a/cmd/serve/front/src/lib/localization/fr.ts
+++ b/cmd/serve/front/src/lib/localization/fr.ts
@@ -1,4 +1,5 @@
import type { AppTranslations, Locale } from '$lib/localization';
+import routes from '$lib/path';
export default {
code: 'fr',
@@ -14,8 +15,7 @@ export default {
'app.not_found':
"Il semblerait que l'application que vous recherchez n'existe pas. Retournez à la",
'app.not_found.cta': "page d'accueil",
- 'app.blankslate.title': 'Aucune application trouvée, commencez par',
- 'app.blankslate.cta': 'en créer une !',
+ 'app.blankslate': `Aucune application pour le moment. Les applications représentent les services que vous souhaitez déployer sur votre infrastructure. Commencez par en créer une ! `,
'app.new': 'Nouvelle application',
'app.edit': "Modifier l'application",
'app.delete': "Supprimer l'application",
@@ -42,9 +42,7 @@ Cette action est IRRÉVERSIBLE et supprimera TOUTES LES DONNÉES associées sur
'app.vcs.help':
"Si vous n'utilisez pas de contrôle de version, vous pourrez toujours déployer manuellement votre application.",
'app.vcs.token': "Jeton d'accès",
- 'app.vcs.token.help.instructions':
- "Jeton utilisé pour vous authentifier auprès du dépôt. Généralement connu sous le nom de Jeton d'accès personnel , vous pouvez trouver des instructions pour",
- 'app.vcs.token.help.leave_empty': ', laissez vide si le dépôt est public.',
+ 'app.vcs.token.help': `Jeton utilisé pour vous authentifier auprès du dépôt. Généralement connu sous le nom de Jeton d'accès personnel , vous pouvez trouver des instructions pour Github et Gitlab , laissez vide si le dépôt est public.`,
'app.environment.production': 'Paramètres de production',
'app.environment.staging': 'Paramètres de staging',
'app.environment.target': 'Cible de déploiement',
@@ -82,8 +80,7 @@ Cette action est IRRÉVERSIBLE et supprimera TOUTES LES DONNÉES associées sur
},
'target.configuring': 'Configuration de la cible en cours',
'target.configuring.description': `L'infrastructure nécessaire est en cours de déploiement, veuillez patienter.`,
- 'target.blankslate.title': 'Aucune cible trouvée, commencez par',
- 'target.blankslate.cta': 'en créer une !',
+ '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.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.`,
@@ -130,6 +127,25 @@ Vous devriez probablement essayer de rendre la cible accessible avant de la supp
'deployment.command.delete_target': 'Suppression de la cible',
'deployment.command.configure_target': 'Configuration de la cible',
'deployment.command.deploy': "Déploiement de l'application",
+ // Registries
+ 'registry.new': 'Nouveau registre',
+ 'registry.blankslate': `Aucun registre pour le moment. Si certaines de vos images sont hébergées sur des registres privés , configurer les ici de manière à les rendre disponibles.`,
+ 'registry.not_found':
+ "Il semblerait que le registre que vous recherchez n'existe pas. Retournez à la",
+ 'registry.not_found.cta': 'page des registres',
+ 'registry.delete': 'Supprimer le registre',
+ 'registry.delete.confirm': (name: string) =>
+ `Voulez-vous vraiment supprimer le registre ${name} ?`,
+ 'registry.delete.failed': 'Erreur de suppression',
+ 'registry.general': 'Paramètres généraux',
+ 'registry.url.help':
+ "Url du registre. Pour un registre privé Docker Hub, utiliser l'url https://index.docker.io/v1/
.",
+ 'registry.authentication': 'Authentification',
+ 'registry.auth': 'Authentification nécessaire',
+ 'registry.auth.help': 'Le registre nécessite t-il une authentification ?',
+ 'registry.username': "Nom d'utilisateur",
+ 'registry.password': 'Mot de passe',
+ 'registry.name.help': `Le nom est utilisé uniquement pour l'affichage. Vous pouvez choisir ce que vous voulez.`,
// Account
'profile.my': 'mon profil',
'profile.logout': 'se déconnecter',
@@ -159,8 +175,10 @@ Vous devriez probablement essayer de rendre la cible accessible avant de la supp
'deployment.promote.confirm': (number: number) =>
`Le déploiement #${number} sera promu sur l'environnement de production. Confirmez-vous cette action ?`,
'deployment.promote.failed': 'Erreur lors de la promotion',
- 'deployment.blankslate.title': 'Aucun déploiement trouvé. Commencez par',
- 'deployment.blankslate.cta': 'en créer un !',
+ 'deployment.blankslate': (app: string) =>
+ `Aucun déploiement trouvé. Commencez par en créer un ! `,
'deployment.environment': 'Environnement',
'deployment.payload': 'Charge utile',
'deployment.payload.copy_curl': 'Copier la commande cURL',
@@ -209,6 +227,9 @@ Vous devriez probablement essayer de rendre la cible accessible avant de la supp
'breadcrumb.target.new': 'Nouvelle cible',
'breadcrumb.target.settings': (name: string) => `Paramètres de ${name}`,
'breadcrumb.jobs': 'Tâches',
+ 'breadcrumb.registries': 'Registres',
+ 'breadcrumb.registry.new': 'Nouveau registre',
+ 'breadcrumb.registry.settings': (name: string) => `Paramètes de ${name}`,
'breadcrumb.profile': 'Profil',
'breadcrumb.not_found': 'Ressource introuvable',
// Footer
diff --git a/cmd/serve/front/src/lib/path.ts b/cmd/serve/front/src/lib/path.ts
index f7a0a65e..c97d0c52 100644
--- a/cmd/serve/front/src/lib/path.ts
+++ b/cmd/serve/front/src/lib/path.ts
@@ -14,7 +14,10 @@ const routes = {
targets: '/targets',
createTarget: '/targets/new',
editTarget: (id: string) => `/targets/${id}/edit`,
- jobs: '/jobs'
+ jobs: '/jobs',
+ registries: '/registries',
+ createRegistry: '/registries/new',
+ editRegistry: (id: string) => `/registries/${id}/edit`
} as const;
export default routes;
diff --git a/cmd/serve/front/src/lib/resources/registries.ts b/cmd/serve/front/src/lib/resources/registries.ts
new file mode 100644
index 00000000..4f4d3815
--- /dev/null
+++ b/cmd/serve/front/src/lib/resources/registries.ts
@@ -0,0 +1,84 @@
+import { POLLING_INTERVAL_MS } from '$lib/config';
+import fetcher, { type FetchOptions, type FetchService, type QueryResult } from '$lib/fetcher';
+import type { ByUserData } from '$lib/resources/users';
+
+export type Registry = {
+ id: string;
+ name: string;
+ url: string;
+ credentials?: Credentials;
+ created_at: string;
+ created_by: ByUserData;
+};
+
+export type Credentials = {
+ username: string;
+ password: string;
+};
+
+export type CreateRegistry = {
+ name: string;
+ url: string;
+ credentials?: Credentials;
+};
+
+export type UpdateRegistry = {
+ name?: string;
+ url?: string;
+ credentials: Patch<{
+ username: string;
+ password?: string;
+ }>;
+};
+
+export interface RegistriesService {
+ create(payload: CreateRegistry): Promise;
+ update(id: string, payload: UpdateRegistry): Promise;
+ delete(id: string): Promise;
+ fetchAll(options?: FetchOptions): Promise;
+ fetchById(id: string, options?: FetchOptions): Promise;
+ queryAll(): QueryResult;
+}
+
+type Options = {
+ pollingInterval: number;
+};
+
+export class RemoteRegistriesService implements RegistriesService {
+ constructor(private readonly _fetcher: FetchService, private readonly _options: Options) {}
+
+ create(payload: CreateRegistry): Promise {
+ return this._fetcher.post('/api/v1/registries', payload);
+ }
+
+ update(id: string, payload: UpdateRegistry): Promise {
+ return this._fetcher.patch(`/api/v1/registries/${id}`, payload);
+ }
+
+ delete(id: string): Promise {
+ return this._fetcher.delete(`/api/v1/registries/${id}`, {
+ invalidate: ['/api/v1/registries'],
+ skipUrlInvalidate: true
+ });
+ }
+
+ queryAll(): QueryResult {
+ return this._fetcher.query('/api/v1/registries', {
+ refreshInterval: this._options.pollingInterval
+ });
+ }
+
+ fetchAll(options?: FetchOptions): Promise {
+ return this._fetcher.get('/api/v1/registries', options);
+ }
+
+ fetchById(id: string, options?: FetchOptions): Promise {
+ return this._fetcher.get(`/api/v1/registries/${id}`, options);
+ }
+}
+
+const service: RegistriesService = new RemoteRegistriesService(fetcher, {
+ pollingInterval: POLLING_INTERVAL_MS
+});
+
+export default service;
diff --git a/cmd/serve/front/src/lib/resources/targets.ts b/cmd/serve/front/src/lib/resources/targets.ts
index 0cfead20..d65e0398 100644
--- a/cmd/serve/front/src/lib/resources/targets.ts
+++ b/cmd/serve/front/src/lib/resources/targets.ts
@@ -70,10 +70,7 @@ export interface TargetsService {
fetchAll(filters?: GetTargetsFilters, options?: FetchOptions): Promise;
fetchById(id: string, options?: FetchOptions): Promise;
queryAll(): QueryResult;
- queryById(id: string): QueryResult;
delete(id: string): Promise;
- // fetchAll(options?: FetchOptions): Promise;
- // fetchById(id: string, options?: FetchOptions): Promise;
}
type Options = {
@@ -121,12 +118,6 @@ export class RemoteTargetsService implements TargetsService {
refreshInterval: this._options.pollingInterval
});
}
-
- queryById(id: string): QueryResult {
- return this._fetcher.query(`/api/v1/targets/${id}`, {
- refreshInterval: this._options.pollingInterval
- });
- }
}
const service: TargetsService = new RemoteTargetsService(fetcher, {
diff --git a/cmd/serve/front/src/routes/(main)/+page.svelte b/cmd/serve/front/src/routes/(main)/+page.svelte
index b2d73c66..cf1c88c3 100644
--- a/cmd/serve/front/src/routes/(main)/+page.svelte
+++ b/cmd/serve/front/src/routes/(main)/+page.svelte
@@ -4,7 +4,6 @@
import Breadcrumb from '$components/breadcrumb.svelte';
import CardsGrid from '$components/cards-grid.svelte';
import Button from '$components/button.svelte';
- import Link from '$components/link.svelte';
import routes from '$lib/path';
import service from '$lib/resources/apps';
import l from '$lib/localization';
@@ -25,8 +24,7 @@
{:else}
- {l.translate('app.blankslate.title')}
- {l.translate('app.blankslate.cta')}
+ {@html l.translate('app.blankslate')}
{/if}
diff --git a/cmd/serve/front/src/routes/(main)/apps/[id]/+page.svelte b/cmd/serve/front/src/routes/(main)/apps/[id]/+page.svelte
index 905fa084..c2a6640a 100644
--- a/cmd/serve/front/src/routes/(main)/apps/[id]/+page.svelte
+++ b/cmd/serve/front/src/routes/(main)/apps/[id]/+page.svelte
@@ -4,7 +4,6 @@
import Button from '$components/button.svelte';
import CleanupNotice from '$components/cleanup-notice.svelte';
import EnvironmentCard from '$components/environment-card.svelte';
- import Link from '$components/link.svelte';
import routes from '$lib/path';
import service from '$lib/resources/apps';
import l from '$lib/localization';
@@ -39,10 +38,7 @@
{:else}
- {l.translate('deployment.blankslate.title')}
-
- {l.translate('deployment.blankslate.cta')}
-
+ {@html l.translate('deployment.blankslate', [data.app.id])}
{/if}
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 cda3560c..80a1878f 100644
--- a/cmd/serve/front/src/routes/(main)/apps/app-form.svelte
+++ b/cmd/serve/front/src/routes/(main)/apps/app-form.svelte
@@ -4,7 +4,6 @@
import FormErrors from '$components/form-errors.svelte';
import FormSection from '$components/form-section.svelte';
import Form from '$components/form.svelte';
- import Link from '$components/link.svelte';
import Panel from '$components/panel.svelte';
import Stack from '$components/stack.svelte';
import TextInput from '$components/text-input.svelte';
@@ -204,20 +203,7 @@
bind:value={token}
>
- {@html l.translate('app.vcs.token.help.instructions')}
- GitHub
- {l.translate('and')}
- GitLab{l.translate('app.vcs.token.help.leave_empty')}
+ {@html l.translate('app.vcs.token.help')}
{/if}
diff --git a/cmd/serve/front/src/routes/(main)/registries/+error.svelte b/cmd/serve/front/src/routes/(main)/registries/+error.svelte
new file mode 100644
index 00000000..517398ae
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/+error.svelte
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+ {l.translate('registry.not_found')}
+ {l.translate('registry.not_found.cta')}.
+
+
diff --git a/cmd/serve/front/src/routes/(main)/registries/+page.svelte b/cmd/serve/front/src/routes/(main)/registries/+page.svelte
new file mode 100644
index 00000000..66a7dd52
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/+page.svelte
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+{#if $data && $data.length > 0}
+
+ {#each $data as registry (registry.id)}
+
+ {/each}
+
+{:else}
+
+
+ {@html l.translate('registry.blankslate')}
+
+
+{/if}
diff --git a/cmd/serve/front/src/routes/(main)/registries/+page.ts b/cmd/serve/front/src/routes/(main)/registries/+page.ts
new file mode 100644
index 00000000..1a771ae3
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/+page.ts
@@ -0,0 +1,9 @@
+import service from '$lib/resources/registries';
+
+export const load = async ({ fetch, depends }) => {
+ const registries = await service.fetchAll({ fetch, depends });
+
+ return {
+ registries
+ };
+};
diff --git a/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.svelte b/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.svelte
new file mode 100644
index 00000000..cc4f6c1c
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.svelte
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.ts b/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.ts
new file mode 100644
index 00000000..59c871b9
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/[id]/edit/+page.ts
@@ -0,0 +1,14 @@
+import service from '$lib/resources/registries';
+import { error } from '@sveltejs/kit';
+
+export const load = async ({ params, fetch, depends }) => {
+ try {
+ const registry = await service.fetchById(params.id, { fetch, depends });
+
+ return {
+ registry
+ };
+ } catch {
+ throw error(404);
+ }
+};
diff --git a/cmd/serve/front/src/routes/(main)/registries/new/+page.svelte b/cmd/serve/front/src/routes/(main)/registries/new/+page.svelte
new file mode 100644
index 00000000..bde74f75
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/new/+page.svelte
@@ -0,0 +1,24 @@
+
+
+
+
+
+
+
diff --git a/cmd/serve/front/src/routes/(main)/registries/registry-form.svelte b/cmd/serve/front/src/routes/(main)/registries/registry-form.svelte
new file mode 100644
index 00000000..492147c0
--- /dev/null
+++ b/cmd/serve/front/src/routes/(main)/registries/registry-form.svelte
@@ -0,0 +1,108 @@
+
+
+
diff --git a/cmd/serve/front/src/routes/(main)/targets/+page.svelte b/cmd/serve/front/src/routes/(main)/targets/+page.svelte
index 590d1e4e..91857ab0 100644
--- a/cmd/serve/front/src/routes/(main)/targets/+page.svelte
+++ b/cmd/serve/front/src/routes/(main)/targets/+page.svelte
@@ -2,7 +2,6 @@
import BlankSlate from '$components/blank-slate.svelte';
import Breadcrumb from '$components/breadcrumb.svelte';
import Button from '$components/button.svelte';
- import Link from '$components/link.svelte';
import CardsGrid from '$components/cards-grid.svelte';
import routes from '$lib/path';
import service from '$lib/resources/targets';
@@ -25,8 +24,7 @@
{:else}
- {l.translate('target.blankslate.title')}
- {l.translate('target.blankslate.cta')}
+ {@html l.translate('target.blankslate')}
{/if}
diff --git a/cmd/serve/front/src/routes/(main)/targets/new/+page.svelte b/cmd/serve/front/src/routes/(main)/targets/new/+page.svelte
index 5c75755c..abe4d302 100644
--- a/cmd/serve/front/src/routes/(main)/targets/new/+page.svelte
+++ b/cmd/serve/front/src/routes/(main)/targets/new/+page.svelte
@@ -3,14 +3,14 @@
import Breadcrumb from '$components/breadcrumb.svelte';
import Button from '$components/button.svelte';
import routes from '$lib/path';
- import AppForm from '../target-form.svelte';
+ import TargetForm from '../target-form.svelte';
import l from '$lib/localization';
import service, { type CreateTarget } from '$lib/resources/targets';
const submit = (data: CreateTarget) => service.create(data).then(() => goto(routes.targets));
-
+
-
+
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 73fdd7a6..b12201ba 100644
--- a/cmd/serve/front/src/routes/(main)/targets/target-form.svelte
+++ b/cmd/serve/front/src/routes/(main)/targets/target-form.svelte
@@ -111,7 +111,7 @@
-
+
{l.translate('target.name.help')}
diff --git a/cmd/serve/front/src/routes/(main)/topbar.svelte b/cmd/serve/front/src/routes/(main)/topbar.svelte
index ae8f58d8..becaad88 100644
--- a/cmd/serve/front/src/routes/(main)/topbar.svelte
+++ b/cmd/serve/front/src/routes/(main)/topbar.svelte
@@ -22,6 +22,7 @@
{l.translate('breadcrumb.applications')}
{l.translate('breadcrumb.targets')}
+ {l.translate('breadcrumb.registries')}
{l.translate('breadcrumb.jobs')}
diff --git a/cmd/serve/registries.go b/cmd/serve/registries.go
new file mode 100644
index 00000000..1e05a989
--- /dev/null
+++ b/cmd/serve/registries.go
@@ -0,0 +1,95 @@
+package serve
+
+import (
+ "github.com/YuukanOO/seelf/internal/deployment/app/create_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/app/delete_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/app/get_registries"
+ "github.com/YuukanOO/seelf/internal/deployment/app/get_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/app/update_registry"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ "github.com/YuukanOO/seelf/pkg/http"
+ "github.com/gin-gonic/gin"
+)
+
+func (s *server) createRegistryHandler() gin.HandlerFunc {
+ return http.Bind(s, func(c *gin.Context, cmd create_registry.Command) error {
+ ctx := c.Request.Context()
+
+ id, err := bus.Send(s.bus, ctx, cmd)
+
+ if err != nil {
+ return err
+ }
+
+ data, err := bus.Send(s.bus, ctx, get_registry.Query{
+ ID: id,
+ })
+
+ if err != nil {
+ return err
+ }
+
+ return http.Created(s, c, data, "/api/v1/registries/%s", id)
+ })
+}
+
+func (s *server) updateRegistryHandler() gin.HandlerFunc {
+ return http.Bind(s, func(c *gin.Context, cmd update_registry.Command) error {
+ cmd.ID = c.Param("id")
+ ctx := c.Request.Context()
+
+ id, err := bus.Send(s.bus, ctx, cmd)
+
+ if err != nil {
+ return err
+ }
+
+ data, err := bus.Send(s.bus, ctx, get_registry.Query{
+ ID: id,
+ })
+
+ if err != nil {
+ return err
+ }
+
+ return http.Ok(c, data)
+ })
+}
+
+func (s *server) deleteRegistryHandler() gin.HandlerFunc {
+ return http.Send(s, func(ctx *gin.Context) error {
+ if _, err := bus.Send(s.bus, ctx.Request.Context(), delete_registry.Command{
+ ID: ctx.Param("id"),
+ }); err != nil {
+ return err
+ }
+
+ return http.NoContent(ctx)
+ })
+}
+
+func (s *server) listRegistriesHandler() gin.HandlerFunc {
+ return http.Send(s, func(c *gin.Context) error {
+ data, err := bus.Send(s.bus, c.Request.Context(), get_registries.Query{})
+
+ if err != nil {
+ return err
+ }
+
+ return http.Ok(c, data)
+ })
+}
+
+func (s *server) getRegistryByIDHandler() gin.HandlerFunc {
+ return http.Send(s, func(c *gin.Context) error {
+ data, err := bus.Send(s.bus, c.Request.Context(), get_registry.Query{
+ ID: c.Param("id"),
+ })
+
+ if err != nil {
+ return err
+ }
+
+ return http.Ok(c, data)
+ })
+}
diff --git a/cmd/serve/server.go b/cmd/serve/server.go
index a9a189b7..6368a91e 100644
--- a/cmd/serve/server.go
+++ b/cmd/serve/server.go
@@ -92,6 +92,11 @@ func newHttpServer(options ServerOptions, root startup.ServerRoot) *server {
v1secured.GET("/targets", s.listTargetsHandler())
v1secured.GET("/targets/:id", s.getTargetByIDHandler())
v1secured.DELETE("/targets/:id", s.deleteTargetHandler())
+ v1secured.POST("/registries", s.createRegistryHandler())
+ v1secured.PATCH("/registries/:id", s.updateRegistryHandler())
+ v1secured.DELETE("/registries/:id", s.deleteRegistryHandler())
+ v1secured.GET("/registries", s.listRegistriesHandler())
+ v1secured.GET("/registries/:id", s.getRegistryByIDHandler())
v1secured.GET("/apps", s.listAppsHandler())
v1secured.POST("/apps", s.createAppHandler())
v1secured.PATCH("/apps/:id", s.updateAppHandler())
diff --git a/cmd/serve/targets.go b/cmd/serve/targets.go
index 1bbb084a..749764bf 100644
--- a/cmd/serve/targets.go
+++ b/cmd/serve/targets.go
@@ -22,7 +22,7 @@ type createTargetBody struct {
func (s *server) createTargetHandler() gin.HandlerFunc {
return http.Bind(s, func(c *gin.Context, body createTargetBody) error {
- var ctx = c.Request.Context()
+ ctx := c.Request.Context()
if dockerBody, isSet := body.Docker.TryGet(); isSet {
body.Provider = dockerBody
@@ -54,7 +54,7 @@ type updateTargetBody struct {
func (s *server) updateTargetHandler() gin.HandlerFunc {
return http.Bind(s, func(c *gin.Context, body updateTargetBody) error {
- var ctx = c.Request.Context()
+ ctx := c.Request.Context()
body.ID = c.Param("id")
diff --git a/internal/deployment/app/create_registry/create_registry.go b/internal/deployment/app/create_registry/create_registry.go
new file mode 100644
index 00000000..0ed7b22c
--- /dev/null
+++ b/internal/deployment/app/create_registry/create_registry.go
@@ -0,0 +1,77 @@
+package create_registry
+
+import (
+ "context"
+
+ 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"
+)
+
+type (
+ Command struct {
+ bus.Command[string]
+
+ Name string `json:"name"`
+ Url string `json:"url"`
+ Credentials monad.Maybe[Credentials] `json:"credentials"`
+ }
+
+ Credentials struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+)
+
+func (Command) Name_() string { return "deployment.command.create_registry" }
+
+func Handler(
+ reader domain.RegistriesReader,
+ writer domain.RegistriesWriter,
+) bus.RequestHandler[string, Command] {
+ return func(ctx context.Context, cmd Command) (string, error) {
+ var url domain.Url
+
+ if err := validate.Struct(validate.Of{
+ "name": validate.Field(cmd.Name, strings.Required),
+ "url": validate.Value(cmd.Url, &url, domain.UrlFrom),
+ "credentials": validate.Maybe(cmd.Credentials, func(creds Credentials) error {
+ return validate.Struct(validate.Of{
+ "username": validate.Field(creds.Username, strings.Required),
+ "password": validate.Field(creds.Password, strings.Required),
+ })
+ }),
+ }); err != nil {
+ return "", err
+ }
+
+ urlRequirement, err := reader.CheckUrlAvailability(ctx, url)
+
+ if err != nil {
+ return "", err
+ }
+
+ if err = urlRequirement.Error(); err != nil {
+ return "", validate.Wrap(err, "url")
+ }
+
+ registry, err := domain.NewRegistry(cmd.Name, urlRequirement, auth.CurrentUser(ctx).MustGet())
+
+ if err != nil {
+ return "", err
+ }
+
+ if credentials, hasCredentials := cmd.Credentials.TryGet(); hasCredentials {
+ registry.UseAuthentication(domain.NewCredentials(credentials.Username, credentials.Password))
+ }
+
+ if err = writer.Write(ctx, ®istry); err != nil {
+ return "", err
+ }
+
+ return string(registry.ID()), nil
+ }
+}
diff --git a/internal/deployment/app/create_registry/create_registry_test.go b/internal/deployment/app/create_registry/create_registry_test.go
new file mode 100644
index 00000000..47fca249
--- /dev/null
+++ b/internal/deployment/app/create_registry/create_registry_test.go
@@ -0,0 +1,64 @@
+package create_registry_test
+
+import (
+ "context"
+ "testing"
+
+ auth "github.com/YuukanOO/seelf/internal/auth/domain"
+ "github.com/YuukanOO/seelf/internal/deployment/app/create_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/internal/deployment/infra/memory"
+ "github.com/YuukanOO/seelf/pkg/apperr"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ "github.com/YuukanOO/seelf/pkg/monad"
+ "github.com/YuukanOO/seelf/pkg/must"
+ "github.com/YuukanOO/seelf/pkg/testutil"
+ "github.com/YuukanOO/seelf/pkg/validate"
+)
+
+func Test_CreateRegistry(t *testing.T) {
+ ctx := auth.WithUserID(context.Background(), "some-uid")
+ sut := func(existing ...*domain.Registry) bus.RequestHandler[string, create_registry.Command] {
+ store := memory.NewRegistriesStore(existing...)
+ return create_registry.Handler(store, store)
+ }
+
+ t.Run("should require valid inputs", func(t *testing.T) {
+ uc := sut()
+ id, err := uc(ctx, create_registry.Command{})
+
+ testutil.ErrorIs(t, validate.ErrValidationFailed, err)
+ testutil.Equals(t, "", id)
+ })
+
+ t.Run("should fail if the url is already taken", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, create_registry.Command{
+ Name: "registry",
+ Url: "http://example.com",
+ })
+
+ testutil.Equals(t, "", id)
+ validationErr, ok := apperr.As[validate.FieldErrors](err)
+ testutil.IsTrue(t, ok)
+ testutil.ErrorIs(t, domain.ErrUrlAlreadyTaken, validationErr["url"])
+ })
+
+ t.Run("should create a new registry if everything is good", func(t *testing.T) {
+ uc := sut()
+
+ id, err := uc(ctx, create_registry.Command{
+ Name: "registry",
+ Url: "http://example.com",
+ Credentials: monad.Value(create_registry.Credentials{
+ Username: "user",
+ Password: "password",
+ }),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ })
+}
diff --git a/internal/deployment/app/delete_registry/delete_registry.go b/internal/deployment/app/delete_registry/delete_registry.go
new file mode 100644
index 00000000..6220d9ab
--- /dev/null
+++ b/internal/deployment/app/delete_registry/delete_registry.go
@@ -0,0 +1,33 @@
+package delete_registry
+
+import (
+ "context"
+
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/pkg/bus"
+)
+
+type Command struct {
+ bus.Command[bus.UnitType]
+
+ ID string `json:"id"`
+}
+
+func (Command) Name_() string { return "deployment.command.delete_registry" }
+
+func Handler(
+ reader domain.RegistriesReader,
+ writer domain.RegistriesWriter,
+) bus.RequestHandler[bus.UnitType, Command] {
+ return func(ctx context.Context, cmd Command) (bus.UnitType, error) {
+ registry, err := reader.GetByID(ctx, domain.RegistryID(cmd.ID))
+
+ if err != nil {
+ return bus.Unit, err
+ }
+
+ registry.Delete()
+
+ return bus.Unit, writer.Write(ctx, ®istry)
+ }
+}
diff --git a/internal/deployment/app/delete_registry/delete_registry_test.go b/internal/deployment/app/delete_registry/delete_registry_test.go
new file mode 100644
index 00000000..f90ad4fa
--- /dev/null
+++ b/internal/deployment/app/delete_registry/delete_registry_test.go
@@ -0,0 +1,44 @@
+package delete_registry_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/YuukanOO/seelf/internal/deployment/app/delete_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/internal/deployment/infra/memory"
+ "github.com/YuukanOO/seelf/pkg/apperr"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ "github.com/YuukanOO/seelf/pkg/must"
+ "github.com/YuukanOO/seelf/pkg/testutil"
+)
+
+func Test_DeleteRegistry(t *testing.T) {
+ sut := func(existing ...*domain.Registry) bus.RequestHandler[bus.UnitType, delete_registry.Command] {
+ store := memory.NewRegistriesStore(existing...)
+ return delete_registry.Handler(store, store)
+ }
+
+ t.Run("should require an existing registry", func(t *testing.T) {
+ uc := sut()
+
+ _, err := uc(context.Background(), delete_registry.Command{
+ ID: "non-existing-id",
+ })
+
+ testutil.ErrorIs(t, apperr.ErrNotFound, err)
+ })
+
+ t.Run("should delete the registry", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ uc := sut(&r)
+
+ _, err := uc(context.Background(), delete_registry.Command{
+ ID: string(r.ID()),
+ })
+
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryDeleted](t, &r, 1)
+ testutil.Equals(t, r.ID(), evt.ID)
+ })
+}
diff --git a/internal/deployment/app/deploy/deploy.go b/internal/deployment/app/deploy/deploy.go
index ed684b31..8a23c2e8 100644
--- a/internal/deployment/app/deploy/deploy.go
+++ b/internal/deployment/app/deploy/deploy.go
@@ -31,6 +31,7 @@ func Handler(
source domain.Source,
provider domain.Provider,
targetsReader domain.TargetsReader,
+ registriesReader domain.RegistriesReader,
) bus.RequestHandler[bus.UnitType, Command] {
return func(ctx context.Context, cmd Command) (result bus.UnitType, finalErr error) {
result = bus.Unit
@@ -83,6 +84,7 @@ func Handler(
var (
deploymentCtx domain.DeploymentContext
services domain.Services
+ registries []domain.Registry
)
// This one is a special case to avoid to avoid many branches
@@ -139,8 +141,13 @@ func Handler(
return
}
+ // Fetch custom registries
+ if registries, finalErr = registriesReader.GetAll(ctx); finalErr != nil {
+ return
+ }
+
// Ask the provider to actually deploy the app
- if services, finalErr = provider.Deploy(ctx, deploymentCtx, depl, target); finalErr != nil {
+ if services, finalErr = provider.Deploy(ctx, deploymentCtx, depl, target, registries); finalErr != nil {
return
}
diff --git a/internal/deployment/app/deploy/deploy_test.go b/internal/deployment/app/deploy/deploy_test.go
index 5e0eeebf..52fef77f 100644
--- a/internal/deployment/app/deploy/deploy_test.go
+++ b/internal/deployment/app/deploy/deploy_test.go
@@ -37,13 +37,14 @@ func Test_Deploy(t *testing.T) {
opts := config.Default(config.WithTestDefaults())
store := memory.NewDeploymentsStore(data.deployments...)
targetsStore := memory.NewTargetsStore(data.targets...)
+ registriesStore := memory.NewRegistriesStore()
artifactManager := artifact.NewLocal(opts, logger)
t.Cleanup(func() {
os.RemoveAll(opts.DataDir())
})
- return deploy.Handler(store, store, artifactManager, source, provider, targetsStore)
+ return deploy.Handler(store, store, artifactManager, source, provider, targetsStore, registriesStore)
}
t.Run("should fail silently if the deployment does not exists", func(t *testing.T) {
@@ -204,6 +205,6 @@ func (b *dummyProvider) Prepare(context.Context, any, ...domain.ProviderConfig)
return nil, nil
}
-func (b *dummyProvider) Deploy(context.Context, domain.DeploymentContext, domain.Deployment, domain.Target) (domain.Services, error) {
+func (b *dummyProvider) Deploy(context.Context, domain.DeploymentContext, domain.Deployment, domain.Target, []domain.Registry) (domain.Services, error) {
return domain.Services{}, b.err
}
diff --git a/internal/deployment/app/get_registries/get_registries.go b/internal/deployment/app/get_registries/get_registries.go
new file mode 100644
index 00000000..fb1ba91c
--- /dev/null
+++ b/internal/deployment/app/get_registries/get_registries.go
@@ -0,0 +1,12 @@
+package get_registries
+
+import (
+ "github.com/YuukanOO/seelf/internal/deployment/app/get_registry"
+ "github.com/YuukanOO/seelf/pkg/bus"
+)
+
+type Query struct {
+ bus.Query[[]get_registry.Registry]
+}
+
+func (Query) Name_() string { return "deployment.query.get_registries" }
diff --git a/internal/deployment/app/get_registry/get_registry.go b/internal/deployment/app/get_registry/get_registry.go
new file mode 100644
index 00000000..31d5bcbd
--- /dev/null
+++ b/internal/deployment/app/get_registry/get_registry.go
@@ -0,0 +1,35 @@
+package get_registry
+
+import (
+ "time"
+
+ "github.com/YuukanOO/seelf/internal/deployment/app"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ "github.com/YuukanOO/seelf/pkg/monad"
+ "github.com/YuukanOO/seelf/pkg/storage"
+)
+
+type (
+ // Retrieve one registry
+ Query struct {
+ bus.Query[Registry]
+
+ ID string `json:"id"`
+ }
+
+ Registry struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Url string `json:"url"`
+ Credentials monad.Maybe[Credentials] `json:"credentials"`
+ CreatedAt time.Time `json:"created_at"`
+ CreatedBy app.UserSummary `json:"created_by"`
+ }
+
+ Credentials struct {
+ Username string `json:"username"`
+ Password storage.SecretString `json:"password"`
+ }
+)
+
+func (Query) Name_() string { return "deployment.query.get_registry" }
diff --git a/internal/deployment/app/update_registry/update_registry.go b/internal/deployment/app/update_registry/update_registry.go
new file mode 100644
index 00000000..a15407bd
--- /dev/null
+++ b/internal/deployment/app/update_registry/update_registry.go
@@ -0,0 +1,102 @@
+package update_registry
+
+import (
+ "context"
+
+ "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"
+)
+
+type (
+ Command struct {
+ bus.Command[string]
+
+ ID string `json:"id"`
+ Name monad.Maybe[string] `json:"name"`
+ Url monad.Maybe[string] `json:"url"`
+ Credentials monad.Patch[Credentials] `json:"credentials"`
+ }
+
+ Credentials struct {
+ Username string `json:"username"`
+ Password monad.Maybe[string] `json:"password"` // Not set if the user wants to keep the current password.
+ }
+)
+
+func (Command) Name_() string { return "deployment.command.update_registry" }
+
+func Handler(
+ reader domain.RegistriesReader,
+ writer domain.RegistriesWriter,
+) bus.RequestHandler[string, Command] {
+ return func(ctx context.Context, cmd Command) (string, error) {
+ var url domain.Url
+
+ if err := validate.Struct(validate.Of{
+ "name": validate.Maybe(cmd.Name, strings.Required),
+ "url": validate.Maybe(cmd.Url, func(u string) error {
+ return validate.Value(u, &url, domain.UrlFrom)
+ }),
+ "credentials": validate.Patch(cmd.Credentials, func(creds Credentials) error {
+ return validate.Struct(validate.Of{
+ "username": validate.Field(creds.Username, strings.Required),
+ "password": validate.Maybe(creds.Password, strings.Required),
+ })
+ }),
+ }); err != nil {
+ return "", err
+ }
+
+ registry, err := reader.GetByID(ctx, domain.RegistryID(cmd.ID))
+
+ if err != nil {
+ return "", err
+ }
+
+ if name, isSet := cmd.Name.TryGet(); isSet {
+ registry.Rename(name)
+ }
+
+ var urlRequirement domain.RegistryUrlRequirement
+
+ if cmd.Url.HasValue() {
+ urlRequirement, err = reader.CheckUrlAvailability(ctx, url, registry.ID())
+
+ if err != nil {
+ return "", err
+ }
+
+ if err = urlRequirement.Error(); err != nil {
+ return "", validate.Wrap(err, "url")
+ }
+
+ if err = registry.HasUrl(urlRequirement); err != nil {
+ return "", err
+ }
+ }
+
+ if credentialsPatch, isSet := cmd.Credentials.TryGet(); isSet {
+ if credentialsUpdate, hasValue := credentialsPatch.TryGet(); hasValue {
+ credentials := registry.Credentials().Get(domain.NewCredentials(credentialsUpdate.Username, credentialsUpdate.Password.Get("")))
+ credentials.HasUsername(credentialsUpdate.Username)
+
+ if newPassword, isSet := credentialsUpdate.Password.TryGet(); isSet {
+ credentials.HasPassword(newPassword)
+ }
+
+ registry.UseAuthentication(credentials)
+ } else {
+ registry.RemoveAuthentication()
+ }
+ }
+
+ if err = writer.Write(ctx, ®istry); err != nil {
+ return "", err
+ }
+
+ return cmd.ID, nil
+ }
+}
diff --git a/internal/deployment/app/update_registry/update_registry_test.go b/internal/deployment/app/update_registry/update_registry_test.go
new file mode 100644
index 00000000..eb7812a0
--- /dev/null
+++ b/internal/deployment/app/update_registry/update_registry_test.go
@@ -0,0 +1,152 @@
+package update_registry_test
+
+import (
+ "context"
+ "testing"
+
+ auth "github.com/YuukanOO/seelf/internal/auth/domain"
+ "github.com/YuukanOO/seelf/internal/deployment/app/update_registry"
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/internal/deployment/infra/memory"
+ "github.com/YuukanOO/seelf/pkg/apperr"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ "github.com/YuukanOO/seelf/pkg/monad"
+ "github.com/YuukanOO/seelf/pkg/must"
+ "github.com/YuukanOO/seelf/pkg/testutil"
+ "github.com/YuukanOO/seelf/pkg/validate"
+)
+
+func Test_UpdateRegistry(t *testing.T) {
+ ctx := auth.WithUserID(context.Background(), "some-uid")
+ sut := func(existing ...*domain.Registry) bus.RequestHandler[string, update_registry.Command] {
+ store := memory.NewRegistriesStore(existing...)
+ return update_registry.Handler(store, store)
+ }
+
+ t.Run("should require valid inputs", func(t *testing.T) {
+ uc := sut()
+
+ id, err := uc(ctx, update_registry.Command{
+ Url: monad.Value("not an url"),
+ })
+
+ testutil.Equals(t, "", id)
+ validationErr, ok := apperr.As[validate.FieldErrors](err)
+ testutil.IsTrue(t, ok)
+ testutil.ErrorIs(t, domain.ErrInvalidUrl, validationErr["url"])
+ })
+
+ t.Run("should require an existing registry", func(t *testing.T) {
+ uc := sut()
+
+ _, err := uc(ctx, update_registry.Command{
+ Url: monad.Value("http://example.com"),
+ })
+
+ testutil.ErrorIs(t, apperr.ErrNotFound, err)
+ })
+
+ t.Run("should rename a registry", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r.ID()),
+ Name: monad.Value("new-name"),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryRenamed](t, &r, 1)
+ testutil.Equals(t, r.ID(), evt.ID)
+ testutil.Equals(t, "new-name", evt.Name)
+ })
+
+ t.Run("should require a unique url when updating it", func(t *testing.T) {
+ r1 := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ r2 := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://localhost:5000")), true), "uid"))
+ uc := sut(&r1, &r2)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r2.ID()),
+ Url: monad.Value("http://example.com"),
+ })
+
+ testutil.Equals(t, "", id)
+ validationErr, ok := apperr.As[validate.FieldErrors](err)
+ testutil.IsTrue(t, ok)
+ testutil.ErrorIs(t, domain.ErrUrlAlreadyTaken, validationErr["url"])
+ })
+
+ t.Run("should update the url if its good", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r.ID()),
+ Url: monad.Value("http://localhost:5000"),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryUrlChanged](t, &r, 1)
+ testutil.Equals(t, r.ID(), evt.ID)
+ testutil.Equals(t, "http://localhost:5000", evt.Url.String())
+ })
+
+ t.Run("should be able to add credentials", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r.ID()),
+ Credentials: monad.PatchValue(update_registry.Credentials{
+ Username: "user",
+ Password: monad.Value("password"),
+ }),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryCredentialsChanged](t, &r, 1)
+ testutil.Equals(t, r.ID(), evt.ID)
+ testutil.Equals(t, "user", evt.Credentials.Username())
+ testutil.Equals(t, "password", evt.Credentials.Password())
+ })
+
+ t.Run("should be able to update only the credentials username", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ r.UseAuthentication(domain.NewCredentials("user", "password"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r.ID()),
+ Credentials: monad.PatchValue(update_registry.Credentials{
+ Username: "new-user",
+ }),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryCredentialsChanged](t, &r, 2)
+ testutil.Equals(t, r.ID(), evt.ID)
+ testutil.Equals(t, "new-user", evt.Credentials.Username())
+ testutil.Equals(t, "password", evt.Credentials.Password())
+ })
+
+ t.Run("should be able to remove authentication", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ r.UseAuthentication(domain.NewCredentials("user", "password"))
+ uc := sut(&r)
+
+ id, err := uc(ctx, update_registry.Command{
+ ID: string(r.ID()),
+ Credentials: monad.Nil[update_registry.Credentials](),
+ })
+
+ testutil.NotEquals(t, "", id)
+ testutil.IsNil(t, err)
+ evt := testutil.EventIs[domain.RegistryCredentialsRemoved](t, &r, 2)
+ testutil.Equals(t, r.ID(), evt.ID)
+ })
+}
diff --git a/internal/deployment/domain/app.go b/internal/deployment/domain/app.go
index 98c7c8a8..974f9e7a 100644
--- a/internal/deployment/domain/app.go
+++ b/internal/deployment/domain/app.go
@@ -207,7 +207,7 @@ func (a *App) UseVersionControl(config VersionControl) error {
return ErrAppCleanupRequested
}
- if existing, isSet := a.versionControl.TryGet(); isSet && config.Equals(existing) {
+ if existing, isSet := a.versionControl.TryGet(); isSet && config == existing {
return nil
}
diff --git a/internal/deployment/domain/credentials.go b/internal/deployment/domain/credentials.go
new file mode 100644
index 00000000..29af49a4
--- /dev/null
+++ b/internal/deployment/domain/credentials.go
@@ -0,0 +1,28 @@
+package domain
+
+// Represents basic credentials used by a registry.
+type Credentials struct {
+ username string
+ password string
+}
+
+// Builds new credentials with the provided username and password.
+func NewCredentials(username, password string) Credentials {
+ return Credentials{
+ username: username,
+ password: password,
+ }
+}
+
+// Updates the username.
+func (c *Credentials) HasUsername(username string) {
+ c.username = username
+}
+
+// Updates the password.
+func (c *Credentials) HasPassword(password string) {
+ c.password = password
+}
+
+func (c Credentials) Username() string { return c.username }
+func (c Credentials) Password() string { return c.password }
diff --git a/internal/deployment/domain/credentials_test.go b/internal/deployment/domain/credentials_test.go
new file mode 100644
index 00000000..f273661a
--- /dev/null
+++ b/internal/deployment/domain/credentials_test.go
@@ -0,0 +1,35 @@
+package domain_test
+
+import (
+ "testing"
+
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/pkg/testutil"
+)
+
+func Test_Credentials(t *testing.T) {
+ t.Run("should be instantiable", func(t *testing.T) {
+ cred := domain.NewCredentials("user", "pass")
+
+ testutil.Equals(t, "user", cred.Username())
+ testutil.Equals(t, "pass", cred.Password())
+ })
+
+ t.Run("should be able to change the username", func(t *testing.T) {
+ cred := domain.NewCredentials("user", "pass")
+
+ cred.HasUsername("newuser")
+
+ testutil.Equals(t, "newuser", cred.Username())
+ testutil.Equals(t, "pass", cred.Password())
+ })
+
+ t.Run("should be able to change the password", func(t *testing.T) {
+ cred := domain.NewCredentials("user", "pass")
+
+ cred.HasPassword("newpass")
+
+ testutil.Equals(t, "user", cred.Username())
+ testutil.Equals(t, "newpass", cred.Password())
+ })
+}
diff --git a/internal/deployment/domain/provider.go b/internal/deployment/domain/provider.go
index 4f955000..a68cc395 100644
--- a/internal/deployment/domain/provider.go
+++ b/internal/deployment/domain/provider.go
@@ -28,7 +28,7 @@ type (
// Prepare the given payload representing a Provider specific configuration.
Prepare(ctx context.Context, payload any, existing ...ProviderConfig) (ProviderConfig, error)
// Deploy a deployment on the specified target and return services that has been deployed.
- Deploy(context.Context, DeploymentContext, Deployment, Target) (Services, error)
+ Deploy(context.Context, DeploymentContext, Deployment, Target, []Registry) (Services, error)
// Setup a target by deploying the needed stuff to actually serve deployments.
Setup(context.Context, Target) (TargetEntrypointsAssigned, error)
// Remove target related configuration.
diff --git a/internal/deployment/domain/registry.go b/internal/deployment/domain/registry.go
new file mode 100644
index 00000000..a7c7cfe3
--- /dev/null
+++ b/internal/deployment/domain/registry.go
@@ -0,0 +1,230 @@
+package domain
+
+import (
+ "context"
+ "time"
+
+ auth "github.com/YuukanOO/seelf/internal/auth/domain"
+ "github.com/YuukanOO/seelf/pkg/bus"
+ shared "github.com/YuukanOO/seelf/pkg/domain"
+ "github.com/YuukanOO/seelf/pkg/event"
+ "github.com/YuukanOO/seelf/pkg/id"
+ "github.com/YuukanOO/seelf/pkg/monad"
+ "github.com/YuukanOO/seelf/pkg/storage"
+)
+
+type (
+ RegistryID string
+
+ // Represents a custom registry to pull images from, not particularly tied
+ // to Docker.
+ Registry struct {
+ event.Emitter
+
+ id RegistryID
+ name string
+ url Url
+ credentials monad.Maybe[Credentials]
+ created shared.Action[auth.UserID]
+ }
+
+ RegistriesReader interface {
+ CheckUrlAvailability(context.Context, Url, ...RegistryID) (RegistryUrlRequirement, error)
+ GetByID(context.Context, RegistryID) (Registry, error)
+ GetAll(context.Context) ([]Registry, error)
+ }
+
+ RegistriesWriter interface {
+ Write(context.Context, ...*Registry) error
+ }
+
+ RegistryCreated struct {
+ bus.Notification
+
+ ID RegistryID
+ Name string
+ Url Url
+ Created shared.Action[auth.UserID]
+ }
+
+ RegistryRenamed struct {
+ bus.Notification
+
+ ID RegistryID
+ Name string
+ }
+
+ RegistryUrlChanged struct {
+ bus.Notification
+
+ ID RegistryID
+ Url Url
+ }
+
+ RegistryCredentialsChanged struct {
+ bus.Notification
+
+ ID RegistryID
+ Credentials Credentials
+ }
+
+ RegistryCredentialsRemoved struct {
+ bus.Notification
+
+ ID RegistryID
+ }
+
+ RegistryDeleted struct {
+ bus.Notification
+
+ ID RegistryID
+ }
+)
+
+func (RegistryCreated) Name_() string { return "deployment.event.registry_created" }
+func (RegistryRenamed) Name_() string { return "deployment.event.registry_renamed" }
+func (RegistryUrlChanged) Name_() string { return "deployment.event.registry_url_changed" }
+func (RegistryDeleted) Name_() string { return "deployment.event.registry_deleted" }
+
+func (RegistryCredentialsChanged) Name_() string {
+ return "deployment.event.registry_credentials_changed"
+}
+func (RegistryCredentialsRemoved) Name_() string {
+ return "deployment.event.registry_credentials_removed"
+}
+
+// Declare a new custom registry at the given URL.
+func NewRegistry(
+ name string,
+ urlRequirement RegistryUrlRequirement,
+ uid auth.UserID,
+) (r Registry, err error) {
+ url, err := urlRequirement.Met()
+
+ if err != nil {
+ return r, err
+ }
+
+ r.apply(RegistryCreated{
+ ID: id.New[RegistryID](),
+ Name: name,
+ Url: url,
+ Created: shared.NewAction(uid),
+ })
+
+ return r, err
+}
+
+// Recreates a registry from the persistent storage.
+func RegistryFrom(scanner storage.Scanner) (r Registry, err error) {
+ var (
+ username monad.Maybe[string]
+ password monad.Maybe[string]
+ createdAt time.Time
+ createdBy auth.UserID
+ )
+
+ err = scanner.Scan(
+ &r.id,
+ &r.name,
+ &r.url,
+ &username,
+ &password,
+ &createdAt,
+ &createdBy,
+ )
+
+ r.created = shared.ActionFrom(createdBy, createdAt)
+
+ if usr, isSet := username.TryGet(); isSet {
+ r.credentials.Set(NewCredentials(usr, password.Get("")))
+ }
+
+ return r, err
+}
+
+// Renames the registry.
+func (r *Registry) Rename(name string) {
+ if r.name == name {
+ return
+ }
+
+ r.apply(RegistryRenamed{
+ ID: r.id,
+ Name: name,
+ })
+}
+
+// Updates the registry URL.
+func (r *Registry) HasUrl(urlRequirement RegistryUrlRequirement) error {
+ url, err := urlRequirement.Met()
+
+ if err != nil {
+ return err
+ }
+
+ if url == r.url {
+ return nil
+ }
+
+ r.apply(RegistryUrlChanged{
+ ID: r.id,
+ Url: url,
+ })
+
+ return nil
+}
+
+// Set the authentication configuration for the registry.
+func (r *Registry) UseAuthentication(credentials Credentials) {
+ if existing, isSet := r.credentials.TryGet(); isSet && existing == credentials {
+ return
+ }
+
+ r.apply(RegistryCredentialsChanged{
+ ID: r.id,
+ Credentials: credentials,
+ })
+}
+
+// Remove the authentication configuration for the registry.
+func (r *Registry) RemoveAuthentication() {
+ if !r.credentials.HasValue() {
+ return
+ }
+
+ r.apply(RegistryCredentialsRemoved{
+ ID: r.id,
+ })
+}
+
+func (r *Registry) Delete() {
+ r.apply(RegistryDeleted{
+ ID: r.id,
+ })
+}
+
+func (r *Registry) ID() RegistryID { return r.id }
+func (r *Registry) Name() string { return r.name }
+func (r *Registry) Url() Url { return r.url }
+func (r *Registry) Credentials() monad.Maybe[Credentials] { return r.credentials }
+
+func (r *Registry) apply(e event.Event) {
+ switch v := e.(type) {
+ case RegistryCreated:
+ r.id = v.ID
+ r.url = v.Url
+ r.name = v.Name
+ r.created = v.Created
+ case RegistryRenamed:
+ r.name = v.Name
+ case RegistryCredentialsChanged:
+ r.credentials.Set(v.Credentials)
+ case RegistryCredentialsRemoved:
+ r.credentials.Unset()
+ case RegistryUrlChanged:
+ r.url = v.Url
+ }
+
+ event.Store(r, e)
+}
diff --git a/internal/deployment/domain/registry_test.go b/internal/deployment/domain/registry_test.go
new file mode 100644
index 00000000..81d83fba
--- /dev/null
+++ b/internal/deployment/domain/registry_test.go
@@ -0,0 +1,96 @@
+package domain_test
+
+import (
+ "testing"
+
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/pkg/must"
+ "github.com/YuukanOO/seelf/pkg/testutil"
+)
+
+func Test_Registry(t *testing.T) {
+ t.Run("should returns an error if the url is not unique", func(t *testing.T) {
+ _, err := domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), false), "uid")
+
+ testutil.ErrorIs(t, domain.ErrUrlAlreadyTaken, err)
+ })
+
+ t.Run("could be created from a valid url", func(t *testing.T) {
+ r, err := domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid")
+
+ testutil.IsNil(t, err)
+ created := testutil.EventIs[domain.RegistryCreated](t, &r, 0)
+ testutil.Equals(t, "http://example.com", created.Url.String())
+ testutil.NotEquals(t, "", created.ID)
+ testutil.Equals(t, "uid", created.Created.By())
+ testutil.IsFalse(t, created.Created.At().IsZero())
+ })
+
+ t.Run("could be renamed and raise the event only if different", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+
+ r.Rename("new registry")
+ r.Rename("new registry")
+
+ testutil.HasNEvents(t, &r, 2)
+
+ renamed := testutil.EventIs[domain.RegistryRenamed](t, &r, 1)
+ testutil.Equals(t, r.ID(), renamed.ID)
+ testutil.Equals(t, "new registry", renamed.Name)
+ })
+
+ t.Run("should require a valid url when updating it", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+
+ err := r.HasUrl(domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://localhost:5000")), false))
+
+ testutil.ErrorIs(t, domain.ErrUrlAlreadyTaken, err)
+ })
+
+ t.Run("could have its url changed and raise the event only if different", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+
+ r.HasUrl(domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true))
+ r.HasUrl(domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://localhost:5000")), true))
+
+ testutil.HasNEvents(t, &r, 2)
+
+ changed := testutil.EventIs[domain.RegistryUrlChanged](t, &r, 1)
+ testutil.Equals(t, r.ID(), changed.ID)
+ testutil.Equals(t, "http://localhost:5000", changed.Url.String())
+ })
+
+ t.Run("could have credentials attached and raise the event only if different", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+
+ r.UseAuthentication(domain.NewCredentials("user", "password"))
+ r.UseAuthentication(domain.NewCredentials("user", "password"))
+
+ testutil.HasNEvents(t, &r, 2)
+
+ changed := testutil.EventIs[domain.RegistryCredentialsChanged](t, &r, 1)
+ testutil.Equals(t, r.ID(), changed.ID)
+ testutil.Equals(t, "user", changed.Credentials.Username())
+ testutil.Equals(t, "password", changed.Credentials.Password())
+ })
+
+ t.Run("could have credentials removed and raise the event once", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+ r.UseAuthentication(domain.NewCredentials("user", "password"))
+
+ r.RemoveAuthentication()
+ r.RemoveAuthentication()
+
+ removed := testutil.EventIs[domain.RegistryCredentialsRemoved](t, &r, 2)
+ testutil.Equals(t, r.ID(), removed.ID)
+ })
+
+ t.Run("could be deleted", func(t *testing.T) {
+ r := must.Panic(domain.NewRegistry("registry", domain.NewRegistryUrlRequirement(must.Panic(domain.UrlFrom("http://example.com")), true), "uid"))
+
+ r.Delete()
+
+ deleted := testutil.EventIs[domain.RegistryDeleted](t, &r, 1)
+ testutil.Equals(t, r.ID(), deleted.ID)
+ })
+}
diff --git a/internal/deployment/domain/requirement.go b/internal/deployment/domain/requirement.go
index 73fb849a..ea00cb02 100644
--- a/internal/deployment/domain/requirement.go
+++ b/internal/deployment/domain/requirement.go
@@ -52,6 +52,28 @@ func (e TargetUrlRequirement) Error() error {
func (e TargetUrlRequirement) Met() (Url, error) { return e.url, e.Error() }
+type RegistryUrlRequirement struct {
+ url Url
+ unique bool
+}
+
+func NewRegistryUrlRequirement(url Url, unique bool) RegistryUrlRequirement {
+ return RegistryUrlRequirement{
+ url: url,
+ unique: unique,
+ }
+}
+
+func (e RegistryUrlRequirement) Error() error {
+ if !e.unique {
+ return ErrUrlAlreadyTaken
+ }
+
+ return nil
+}
+
+func (e RegistryUrlRequirement) Met() (Url, error) { return e.url, e.Error() }
+
type ProviderConfigRequirement struct {
config ProviderConfig
unique bool
diff --git a/internal/deployment/domain/target.go b/internal/deployment/domain/target.go
index 633234ce..149a3310 100644
--- a/internal/deployment/domain/target.go
+++ b/internal/deployment/domain/target.go
@@ -241,7 +241,7 @@ func (t *Target) HasUrl(urlRequirement TargetUrlRequirement) error {
return err
}
- if t.url.Equals(url) {
+ if t.url == url {
return nil
}
diff --git a/internal/deployment/domain/url.go b/internal/deployment/domain/url.go
index 789026c0..9752f895 100644
--- a/internal/deployment/domain/url.go
+++ b/internal/deployment/domain/url.go
@@ -13,7 +13,8 @@ const schemeHttps = "https"
// Url struct which embed an url.URL struct and provides additional methods and meaning.
type Url struct {
- value *url.URL
+ value url.URL
+ user monad.Maybe[url.Userinfo]
}
var ErrInvalidUrl = apperr.New("invalid_url")
@@ -26,17 +27,26 @@ func UrlFrom(raw string) (Url, error) {
return Url{}, ErrInvalidUrl
}
- return Url{u}, nil
+ var result Url
+
+ // We want to get rid of the pointer part so equality could work without specific handling
+ if u.User != nil {
+ result.user.Set(*u.User)
+ u.User = nil
+ }
+
+ result.value = *u
+
+ return result, nil
}
-func (u Url) Host() string { return u.value.Host }
-func (u Url) UseSSL() bool { return u.value.Scheme == schemeHttps }
-func (u Url) Equals(other Url) bool { return u.value.String() == other.value.String() }
+func (u Url) Host() string { return u.value.Host }
+func (u Url) UseSSL() bool { return u.value.Scheme == schemeHttps }
// Returns the user part of the url if any.
func (u Url) User() (m monad.Maybe[string]) {
- if u.value.User != nil {
- m.Set(u.value.User.Username())
+ if usr, hasUser := u.user.TryGet(); hasUser {
+ m.Set(usr.Username())
}
return m
@@ -44,28 +54,33 @@ func (u Url) User() (m monad.Maybe[string]) {
// Returns the root part of an url.
func (u Url) Root() Url {
- url := *u.value
- url.RawQuery = ""
- url.Path = ""
- return Url{&url}
+ u.value.RawQuery = ""
+ u.value.Path = ""
+ return u
}
// Returns a new url representing a subdomain.
func (u Url) SubDomain(subdomain string) Url {
- url := *u.value
// FIXME: should we validate the given subdomain here? Or at least encode it
- url.Host = subdomain + "." + u.Host()
- return Url{&url}
+ u.value.Host = subdomain + "." + u.Host()
+ return u
}
// Returns a new url without the user part.
func (u Url) WithoutUser() Url {
- url := *u.value
- url.User = nil
- return Url{&url}
+ u.user.Unset()
+ return u
}
-func (u Url) String() string { return u.value.String() }
+func (u Url) String() string {
+ raw := u.value
+
+ if usr, hasUser := u.user.TryGet(); hasUser {
+ raw.User = &usr
+ }
+
+ return raw.String()
+}
func (u Url) Value() (driver.Value, error) {
return u.value.String(), nil
@@ -78,7 +93,7 @@ func (u *Url) Scan(value any) error {
return err
}
- u.value = url.value
+ *u = url
return nil
}
diff --git a/internal/deployment/domain/url_test.go b/internal/deployment/domain/url_test.go
index 56f3519c..b3d7fe7b 100644
--- a/internal/deployment/domain/url_test.go
+++ b/internal/deployment/domain/url_test.go
@@ -18,6 +18,7 @@ func Test_Url(t *testing.T) {
{"something.com", false},
{"http://something.com", true},
{"https://something.secure.com", true},
+ {"http://127.0.0.1", true},
}
for _, test := range tests {
@@ -103,6 +104,7 @@ func Test_Url(t *testing.T) {
url := must.Panic(domain.UrlFrom("http://seelf@docker.localhost"))
testutil.Equals(t, "http://docker.localhost", url.WithoutUser().String())
+ testutil.Equals(t, "http://seelf@docker.localhost", url.String())
url = must.Panic(domain.UrlFrom("http://docker.localhost"))
@@ -113,5 +115,6 @@ func Test_Url(t *testing.T) {
url := must.Panic(domain.UrlFrom("http://docker.localhost/some/path?query=value"))
testutil.Equals(t, "http://docker.localhost", url.Root().String())
+ testutil.Equals(t, "http://docker.localhost/some/path?query=value", url.String())
})
}
diff --git a/internal/deployment/domain/version_control.go b/internal/deployment/domain/version_control.go
index 2438984a..248aad3c 100644
--- a/internal/deployment/domain/version_control.go
+++ b/internal/deployment/domain/version_control.go
@@ -34,7 +34,3 @@ func (c *VersionControl) HasUrl(url Url) {
func (c VersionControl) Url() Url { return c.url }
func (c VersionControl) Token() monad.Maybe[string] { return c.token }
-
-func (c VersionControl) Equals(other VersionControl) bool {
- return other.url.Equals(c.url) && other.token == c.token
-}
diff --git a/internal/deployment/domain/version_control_test.go b/internal/deployment/domain/version_control_test.go
index 570461cb..659c0deb 100644
--- a/internal/deployment/domain/version_control_test.go
+++ b/internal/deployment/domain/version_control_test.go
@@ -132,7 +132,7 @@ func Test_VersionControl(t *testing.T) {
f := tt.first()
s := tt.second()
t.Run(fmt.Sprintf("%v %v", f, s), func(t *testing.T) {
- testutil.Equals(t, tt.expected, f.Equals(s))
+ testutil.Equals(t, tt.expected, f == s)
})
}
})
diff --git a/internal/deployment/infra/memory/registries.go b/internal/deployment/infra/memory/registries.go
new file mode 100644
index 00000000..6889751b
--- /dev/null
+++ b/internal/deployment/infra/memory/registries.go
@@ -0,0 +1,110 @@
+package memory
+
+import (
+ "context"
+ "slices"
+
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/pkg/apperr"
+ "github.com/YuukanOO/seelf/pkg/event"
+)
+
+type (
+ RegistriesStore interface {
+ domain.RegistriesReader
+ domain.RegistriesWriter
+ }
+
+ registriesStore struct {
+ registries []*registryData
+ }
+
+ registryData struct {
+ id domain.RegistryID
+ value *domain.Registry
+ }
+)
+
+func NewRegistriesStore(existingApps ...*domain.Registry) RegistriesStore {
+ s := ®istriesStore{}
+
+ s.Write(context.Background(), existingApps...)
+
+ return s
+}
+
+func (s *registriesStore) CheckUrlAvailability(ctx context.Context, domainUrl domain.Url, excluded ...domain.RegistryID) (domain.RegistryUrlRequirement, error) {
+ var registry *domain.Registry
+
+ for _, t := range s.registries {
+ if t.value.Url() == domainUrl {
+ registry = t.value
+ break
+ }
+ }
+
+ return domain.NewRegistryUrlRequirement(domainUrl, registry == nil || slices.Contains(excluded, registry.ID())), nil
+}
+
+func (s *registriesStore) GetByID(ctx context.Context, id domain.RegistryID) (domain.Registry, error) {
+ for _, r := range s.registries {
+ if r.id == id {
+ return *r.value, nil
+ }
+ }
+
+ return domain.Registry{}, apperr.ErrNotFound
+}
+
+func (s *registriesStore) GetAll(ctx context.Context) ([]domain.Registry, error) {
+ var registries []domain.Registry
+
+ for _, r := range s.registries {
+ registries = append(registries, *r.value)
+ }
+
+ return registries, nil
+}
+
+func (s *registriesStore) Write(ctx context.Context, registries ...*domain.Registry) error {
+ for _, reg := range registries {
+ for _, e := range event.Unwrap(reg) {
+ switch evt := e.(type) {
+ case domain.RegistryCreated:
+ var exist bool
+ for _, r := range s.registries {
+ if r.id == evt.ID {
+ exist = true
+ break
+ }
+ }
+
+ if exist {
+ continue
+ }
+
+ s.registries = append(s.registries, ®istryData{
+ id: evt.ID,
+ value: reg,
+ })
+ case domain.RegistryDeleted:
+ for i, r := range s.registries {
+ if r.id == reg.ID() {
+ *r.value = *reg
+ s.registries = append(s.registries[:i], s.registries[i+1:]...)
+ break
+ }
+ }
+ default:
+ for _, r := range s.registries {
+ if r.id == reg.ID() {
+ *r.value = *reg
+ break
+ }
+ }
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/internal/deployment/infra/mod.go b/internal/deployment/infra/mod.go
index fdfb9d02..ee0db6f4 100644
--- a/internal/deployment/infra/mod.go
+++ b/internal/deployment/infra/mod.go
@@ -8,8 +8,10 @@ import (
"github.com/YuukanOO/seelf/internal/deployment/app/cleanup_target"
"github.com/YuukanOO/seelf/internal/deployment/app/configure_target"
"github.com/YuukanOO/seelf/internal/deployment/app/create_app"
+ "github.com/YuukanOO/seelf/internal/deployment/app/create_registry"
"github.com/YuukanOO/seelf/internal/deployment/app/create_target"
"github.com/YuukanOO/seelf/internal/deployment/app/delete_app"
+ "github.com/YuukanOO/seelf/internal/deployment/app/delete_registry"
"github.com/YuukanOO/seelf/internal/deployment/app/delete_target"
"github.com/YuukanOO/seelf/internal/deployment/app/deploy"
"github.com/YuukanOO/seelf/internal/deployment/app/expose_seelf_container"
@@ -22,6 +24,7 @@ import (
"github.com/YuukanOO/seelf/internal/deployment/app/request_app_cleanup"
"github.com/YuukanOO/seelf/internal/deployment/app/request_target_cleanup"
"github.com/YuukanOO/seelf/internal/deployment/app/update_app"
+ "github.com/YuukanOO/seelf/internal/deployment/app/update_registry"
"github.com/YuukanOO/seelf/internal/deployment/app/update_target"
"github.com/YuukanOO/seelf/internal/deployment/domain"
"github.com/YuukanOO/seelf/internal/deployment/infra/artifact"
@@ -54,6 +57,7 @@ func Setup(
appsStore := deploymentsqlite.NewAppsStore(db)
deploymentsStore := deploymentsqlite.NewDeploymentsStore(db)
targetsStore := deploymentsqlite.NewTargetsStore(db)
+ registriesStore := deploymentsqlite.NewRegistriesStore(db)
deploymentQueryHandler := deploymentsqlite.NewGateway(db)
artifactManager := artifact.NewLocal(opts, logger)
@@ -73,7 +77,7 @@ func Setup(
bus.Register(b, create_app.Handler(appsStore, appsStore))
bus.Register(b, update_app.Handler(appsStore, appsStore))
bus.Register(b, queue_deployment.Handler(appsStore, deploymentsStore, deploymentsStore, sourceFacade))
- bus.Register(b, deploy.Handler(deploymentsStore, deploymentsStore, artifactManager, sourceFacade, providerFacade, targetsStore))
+ bus.Register(b, deploy.Handler(deploymentsStore, deploymentsStore, artifactManager, sourceFacade, providerFacade, targetsStore, registriesStore))
bus.Register(b, request_app_cleanup.Handler(appsStore, appsStore))
bus.Register(b, delete_app.Handler(appsStore, appsStore, artifactManager))
bus.Register(b, cleanup_app.Handler(targetsStore, deploymentsStore, providerFacade))
@@ -87,12 +91,17 @@ func Setup(
bus.Register(b, request_target_cleanup.Handler(targetsStore, targetsStore, appsStore))
bus.Register(b, cleanup_target.Handler(targetsStore, deploymentsStore, providerFacade))
bus.Register(b, delete_target.Handler(targetsStore, targetsStore, providerFacade))
+ bus.Register(b, create_registry.Handler(registriesStore, registriesStore))
+ bus.Register(b, update_registry.Handler(registriesStore, registriesStore))
+ bus.Register(b, delete_registry.Handler(registriesStore, registriesStore))
bus.Register(b, deploymentQueryHandler.GetAllApps)
bus.Register(b, deploymentQueryHandler.GetAppByID)
bus.Register(b, deploymentQueryHandler.GetAllDeploymentsByApp)
bus.Register(b, deploymentQueryHandler.GetDeploymentByID)
bus.Register(b, deploymentQueryHandler.GetAllTargets)
bus.Register(b, deploymentQueryHandler.GetTargetByID)
+ bus.Register(b, deploymentQueryHandler.GetRegistries)
+ bus.Register(b, deploymentQueryHandler.GetRegistryByID)
bus.On(b, deploy.OnDeploymentCreatedHandler(scheduler))
bus.On(b, redeploy.OnAppEnvChangedHandler(appsStore, deploymentsStore, deploymentsStore))
diff --git a/internal/deployment/infra/provider/docker/client.go b/internal/deployment/infra/provider/docker/client.go
index d68a3ace..6951f8c6 100644
--- a/internal/deployment/infra/provider/docker/client.go
+++ b/internal/deployment/infra/provider/docker/client.go
@@ -4,9 +4,11 @@ import (
"context"
"io"
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
"github.com/YuukanOO/seelf/pkg/monad"
"github.com/YuukanOO/seelf/pkg/ssh"
"github.com/docker/cli/cli/command"
+ clitypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/flags"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
@@ -20,13 +22,14 @@ import (
// Wraps a docker API client and compose service and expose some utility methods.
type client struct {
- cli command.Cli
- api dclient.APIClient
- compose api.Service
- version string
+ cli command.Cli
+ api dclient.APIClient
+ compose api.Service
+ version string
+ registries []string
}
-func connect(ctx context.Context, out io.Writer, host monad.Maybe[ssh.Host]) (*client, error) {
+func connect(ctx context.Context, out io.Writer, host monad.Maybe[ssh.Host], registries ...domain.Registry) (*client, error) {
stream := io.Discard
if out != nil {
@@ -56,11 +59,34 @@ func connect(ctx context.Context, out io.Writer, host monad.Maybe[ssh.Host]) (*c
return nil, err
}
+ // Apply custom registries auth configuration
+ cfg := dockerCli.ConfigFile()
+ registriesNames := make([]string, len(registries))
+
+ cfg.CredentialsStore = "" // Fallback to the default credential store reading from the config file directly
+ cfg.AuthConfigs = make(map[string]clitypes.AuthConfig, len(registries))
+
+ for i, registry := range registries {
+ registriesNames[i] = registry.Name()
+
+ conf := clitypes.AuthConfig{
+ ServerAddress: registry.Url().String(),
+ }
+
+ if auth, hasAuth := registry.Credentials().TryGet(); hasAuth {
+ conf.Username = auth.Username()
+ conf.Password = auth.Password()
+ }
+
+ cfg.AuthConfigs[conf.ServerAddress] = conf
+ }
+
return &client{
- cli: dockerCli,
- api: dockerCli.Client(),
- version: ping.APIVersion,
- compose: compose.NewComposeService(dockerCli),
+ cli: dockerCli,
+ api: dockerCli.Client(),
+ version: ping.APIVersion,
+ registries: registriesNames,
+ compose: compose.NewComposeService(dockerCli),
}, nil
}
diff --git a/internal/deployment/infra/provider/docker/data_test.go b/internal/deployment/infra/provider/docker/data_test.go
index e6c5488b..16a0e004 100644
--- a/internal/deployment/infra/provider/docker/data_test.go
+++ b/internal/deployment/infra/provider/docker/data_test.go
@@ -1,6 +1,7 @@
package docker_test
import (
+ "fmt"
"testing"
"github.com/YuukanOO/seelf/internal/deployment/domain"
@@ -12,27 +13,52 @@ import (
func Test_Data(t *testing.T) {
t.Run("should be comparable", func(t *testing.T) {
- var (
- c1 domain.ProviderConfig = docker.Data{
- Host: monad.Value[ssh.Host]("testdata"),
- User: monad.Value("test"),
- }
- c2 domain.ProviderConfig = docker.Data{
- Host: monad.Value[ssh.Host]("testdata"),
- User: monad.Value("test"),
- }
- c3 domain.ProviderConfig = docker.Data{
- Host: monad.Value[ssh.Host]("testdata"),
- }
- c4 domain.ProviderConfig = otherDataSameProperties{
- Host: monad.Value[ssh.Host]("testdata"),
- User: monad.Value("test"),
- }
- )
+ tests := []struct {
+ a domain.ProviderConfig
+ b domain.ProviderConfig
+ expected bool
+ }{
+ {
+ a: docker.Data{
+ Host: monad.Value[ssh.Host]("testdata"),
+ User: monad.Value("test"),
+ },
+ b: docker.Data{
+ Host: monad.Value[ssh.Host]("testdata"),
+ User: monad.Value("test"),
+ },
+ expected: true,
+ },
+ {
+ a: docker.Data{
+ Host: monad.Value[ssh.Host]("testdata"),
+ User: monad.Value("test"),
+ },
+ b: docker.Data{
+ Host: monad.Value[ssh.Host]("testdata"),
+ },
+ expected: false,
+ },
+ {
+ a: docker.Data{
+ Host: monad.Value[ssh.Host]("testdata"),
+ User: monad.Value("test"),
+ },
+ b: otherDataSameProperties{
+ Host: monad.Value[ssh.Host]("testdata"),
+ User: monad.Value("test"),
+ },
+ expected: false,
+ },
+ }
- testutil.IsTrue(t, c1.Equals(c2))
- testutil.IsFalse(t, c1.Equals(c3))
- testutil.IsFalse(t, c2.Equals(c4))
+ for _, test := range tests {
+ t.Run(fmt.Sprintf("%v", test), func(t *testing.T) {
+ got := test.a.Equals(test.b)
+
+ testutil.Equals(t, test.expected, got)
+ })
+ }
})
}
diff --git a/internal/deployment/infra/provider/docker/provider.go b/internal/deployment/infra/provider/docker/provider.go
index d9ec8a17..5f4d12d5 100644
--- a/internal/deployment/infra/provider/docker/provider.go
+++ b/internal/deployment/infra/provider/docker/provider.go
@@ -221,9 +221,15 @@ func (d *docker) Expose(ctx context.Context, target domain.Target, container str
return client.api.ContainerRestart(ctx, container, dockercontainer.StopOptions{})
}
-func (d *docker) Deploy(ctx context.Context, deploymentCtx domain.DeploymentContext, depl domain.Deployment, target domain.Target) (domain.Services, error) {
+func (d *docker) Deploy(
+ ctx context.Context,
+ deploymentCtx domain.DeploymentContext,
+ depl domain.Deployment,
+ target domain.Target,
+ registries []domain.Registry,
+) (domain.Services, error) {
logger := deploymentCtx.Logger()
- client, err := d.connect(ctx, logger, target)
+ client, err := d.connect(ctx, logger, target, registries...)
if err != nil {
logger.Error(err)
@@ -234,6 +240,10 @@ func (d *docker) Deploy(ctx context.Context, deploymentCtx domain.DeploymentCont
logger.Stepf("successfully connected to docker version %s", client.version)
+ if len(client.registries) > 0 {
+ logger.Infof("using custom registries: %s", strings.Join(client.registries, ", "))
+ }
+
project, services, err := newDeploymentProjectBuilder(deploymentCtx, depl).Build(ctx)
if err != nil {
@@ -320,24 +330,24 @@ func (d *docker) Cleanup(ctx context.Context, app domain.AppID, target domain.Ta
))
}
-func (d *docker) tryConnect(ctx context.Context, out io.Writer, host monad.Maybe[ssh.Host]) (*client, error) {
+func (d *docker) tryConnect(ctx context.Context, out io.Writer, host monad.Maybe[ssh.Host], registries ...domain.Registry) (*client, error) {
// For tests, bypass the initialization and use the provided one
if d.client != nil {
return d.client, nil
}
- return connect(ctx, out, host)
+ return connect(ctx, out, host, registries...)
}
// Connect to the docker daemon and return a new docker cli and compose service.
-func (d *docker) connect(ctx context.Context, logger domain.DeploymentLogger, target domain.Target) (*client, error) {
+func (d *docker) connect(ctx context.Context, logger domain.DeploymentLogger, target domain.Target, registries ...domain.Registry) (*client, error) {
data, ok := target.Provider().(Data)
if !ok {
return nil, domain.ErrInvalidProviderPayload
}
- return d.tryConnect(ctx, logger, data.Host)
+ return d.tryConnect(ctx, logger, data.Host, registries...)
}
func (d *docker) configureTargetSSH(id domain.TargetID, config Data) error {
diff --git a/internal/deployment/infra/provider/docker/provider_test.go b/internal/deployment/infra/provider/docker/provider_test.go
index f243a311..5febbdee 100644
--- a/internal/deployment/infra/provider/docker/provider_test.go
+++ b/internal/deployment/infra/provider/docker/provider_test.go
@@ -207,7 +207,7 @@ wSD0v0RcmkITP1ZR0AAAAYcHF1ZXJuYUBMdWNreUh5ZHJvLmxvY2FsAQID
data, err := provider.Prepare(context.Background(), tt.payload, tt.existing...)
testutil.IsNil(t, err)
- testutil.IsTrue(t, tt.expected == data)
+ testutil.IsTrue(t, data.Equals(tt.expected))
})
}
})
@@ -548,7 +548,7 @@ volumes:
provider, mock := sut(opts)
- services, err := provider.Deploy(context.Background(), ctx, depl, target)
+ services, err := provider.Deploy(context.Background(), ctx, depl, target, nil)
testutil.IsNil(t, err)
testutil.HasLength(t, mock.ups, 1)
diff --git a/internal/deployment/infra/provider/facade.go b/internal/deployment/infra/provider/facade.go
index 5e6e4e1c..d9b91d8b 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) (domain.Services, error) {
+func (f *facade) Deploy(ctx context.Context, info domain.DeploymentContext, depl 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)
+ return provider.Deploy(ctx, info, depl, 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 f559c562..83eb670e 100644
--- a/internal/deployment/infra/provider/facade_test.go
+++ b/internal/deployment/infra/provider/facade_test.go
@@ -29,7 +29,7 @@ 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()
- _, err := sut.Deploy(context.Background(), domain.DeploymentContext{}, depl, target)
+ _, err := sut.Deploy(context.Background(), domain.DeploymentContext{}, depl, target, nil)
testutil.ErrorIs(t, domain.ErrNoValidProviderFound, err)
})
diff --git a/internal/deployment/infra/sqlite/gateway.go b/internal/deployment/infra/sqlite/gateway.go
index 6ba94171..ea1da94d 100644
--- a/internal/deployment/infra/sqlite/gateway.go
+++ b/internal/deployment/infra/sqlite/gateway.go
@@ -10,6 +10,8 @@ import (
"github.com/YuukanOO/seelf/internal/deployment/app/get_app_detail"
"github.com/YuukanOO/seelf/internal/deployment/app/get_apps"
"github.com/YuukanOO/seelf/internal/deployment/app/get_deployment"
+ "github.com/YuukanOO/seelf/internal/deployment/app/get_registries"
+ "github.com/YuukanOO/seelf/internal/deployment/app/get_registry"
"github.com/YuukanOO/seelf/internal/deployment/app/get_target"
"github.com/YuukanOO/seelf/internal/deployment/app/get_targets"
"github.com/YuukanOO/seelf/internal/deployment/domain"
@@ -196,6 +198,41 @@ func (s *gateway) GetTargetByID(ctx context.Context, cmd get_target.Query) (get_
One(s.db, ctx, targetMapper)
}
+func (s *gateway) GetRegistries(ctx context.Context, cmd get_registries.Query) ([]get_registry.Registry, error) {
+ return builder.
+ Query[get_registry.Registry](`
+ SELECT
+ registries.id
+ ,registries.name
+ ,registries.url
+ ,registries.credentials_username
+ ,registries.credentials_password
+ ,registries.created_at
+ ,users.id
+ ,users.email
+ FROM registries
+ INNER JOIN users ON users.id = registries.created_by`).
+ All(s.db, ctx, registryMapper)
+}
+
+func (s *gateway) GetRegistryByID(ctx context.Context, cmd get_registry.Query) (get_registry.Registry, error) {
+ return builder.
+ Query[get_registry.Registry](`
+ SELECT
+ registries.id
+ ,registries.name
+ ,registries.url
+ ,registries.credentials_username
+ ,registries.credentials_password
+ ,registries.created_at
+ ,users.id
+ ,users.email
+ FROM registries
+ INNER JOIN users ON users.id = registries.created_by
+ WHERE registries.id = ?`, cmd.ID).
+ One(s.db, ctx, registryMapper)
+}
+
var getDeploymentDataloader = builder.NewDataloader(
func(a get_apps.App) string { return a.ID },
func(e builder.Executor, ctx context.Context, kr builder.KeyedResult[get_apps.App]) error {
@@ -559,3 +596,34 @@ func targetMapper(scanner storage.Scanner) (t get_target.Target, err error) {
return t, err
}
+
+func registryMapper(scanner storage.Scanner) (r get_registry.Registry, err error) {
+ var (
+ credentialsUsername monad.Maybe[string]
+ credentialsPassword monad.Maybe[storage.SecretString]
+ )
+
+ err = scanner.Scan(
+ &r.ID,
+ &r.Name,
+ &r.Url,
+ &credentialsUsername,
+ &credentialsPassword,
+ &r.CreatedAt,
+ &r.CreatedBy.ID,
+ &r.CreatedBy.Email,
+ )
+
+ if err != nil {
+ return r, err
+ }
+
+ if usr, isSet := credentialsUsername.TryGet(); isSet {
+ r.Credentials.Set(get_registry.Credentials{
+ Username: usr,
+ Password: credentialsPassword.Get(""),
+ })
+ }
+
+ return r, err
+}
diff --git a/internal/deployment/infra/sqlite/migrations/1716482091_create_registries.up.sql b/internal/deployment/infra/sqlite/migrations/1716482091_create_registries.up.sql
new file mode 100644
index 00000000..406f1496
--- /dev/null
+++ b/internal/deployment/infra/sqlite/migrations/1716482091_create_registries.up.sql
@@ -0,0 +1,12 @@
+CREATE TABLE registries (
+ id TEXT NOT NULL
+ ,name TEXT NOT NULL
+ ,url TEXT NOT NULL
+ ,credentials_username TEXT NULL
+ ,credentials_password TEXT NULL
+ ,created_at DATETIME NOT NULL
+ ,created_by TEXT NOT NULL
+ ,CONSTRAINT pk_registries PRIMARY KEY(id)
+ ,CONSTRAINT unique_registries_url UNIQUE(url)
+ ,CONSTRAINT fk_registries_created_by FOREIGN KEY(created_by) REFERENCES users(id) ON DELETE CASCADE
+);
\ No newline at end of file
diff --git a/internal/deployment/infra/sqlite/registries.go b/internal/deployment/infra/sqlite/registries.go
new file mode 100644
index 00000000..5b0d859d
--- /dev/null
+++ b/internal/deployment/infra/sqlite/registries.go
@@ -0,0 +1,119 @@
+package sqlite
+
+import (
+ "context"
+
+ "github.com/YuukanOO/seelf/internal/deployment/domain"
+ "github.com/YuukanOO/seelf/pkg/event"
+ "github.com/YuukanOO/seelf/pkg/storage/sqlite"
+ "github.com/YuukanOO/seelf/pkg/storage/sqlite/builder"
+)
+
+type (
+ RegistriesStore interface {
+ domain.RegistriesReader
+ domain.RegistriesWriter
+ }
+
+ registriesStore struct {
+ db *sqlite.Database
+ }
+)
+
+func NewRegistriesStore(db *sqlite.Database) RegistriesStore {
+ return ®istriesStore{db}
+}
+
+func (s *registriesStore) CheckUrlAvailability(ctx context.Context, url domain.Url, excluded ...domain.RegistryID) (domain.RegistryUrlRequirement, error) {
+ unique, err := builder.
+ Query[bool]("SELECT NOT EXISTS(SELECT 1 FROM registries WHERE url = ?", url).
+ S(builder.Array("AND id NOT IN", excluded)).
+ F(")").
+ Extract(s.db, ctx)
+
+ return domain.NewRegistryUrlRequirement(url, unique), err
+}
+
+func (s *registriesStore) GetByID(ctx context.Context, id domain.RegistryID) (domain.Registry, error) {
+ return builder.
+ Query[domain.Registry](`
+ SELECT
+ id
+ ,name
+ ,url
+ ,credentials_username
+ ,credentials_password
+ ,created_at
+ ,created_by
+ FROM registries
+ WHERE id = ?`, id).
+ One(s.db, ctx, domain.RegistryFrom)
+}
+
+func (s *registriesStore) GetAll(ctx context.Context) ([]domain.Registry, error) {
+ return builder.
+ Query[domain.Registry](`
+ SELECT
+ id
+ ,name
+ ,url
+ ,credentials_username
+ ,credentials_password
+ ,created_at
+ ,created_by
+ FROM registries`).
+ All(s.db, ctx, domain.RegistryFrom)
+}
+
+func (s *registriesStore) Write(ctx context.Context, registries ...*domain.Registry) error {
+ return sqlite.WriteAndDispatch(s.db, ctx, registries, func(ctx context.Context, e event.Event) error {
+ switch evt := e.(type) {
+ case domain.RegistryCreated:
+ return builder.
+ Insert("registries", builder.Values{
+ "id": evt.ID,
+ "name": evt.Name,
+ "url": evt.Url,
+ "created_at": evt.Created.At(),
+ "created_by": evt.Created.By(),
+ }).
+ Exec(s.db, ctx)
+ case domain.RegistryRenamed:
+ return builder.
+ Update("registries", builder.Values{
+ "name": evt.Name,
+ }).
+ F("WHERE id = ?", evt.ID).
+ Exec(s.db, ctx)
+ case domain.RegistryUrlChanged:
+ return builder.
+ Update("registries", builder.Values{
+ "url": evt.Url,
+ }).
+ F("WHERE id = ?", evt.ID).
+ Exec(s.db, ctx)
+ case domain.RegistryCredentialsChanged:
+ return builder.
+ Update("registries", builder.Values{
+ "credentials_username": evt.Credentials.Username(),
+ "credentials_password": evt.Credentials.Password(),
+ }).
+ F("WHERE id = ?", evt.ID).
+ Exec(s.db, ctx)
+ case domain.RegistryCredentialsRemoved:
+ return builder.
+ Update("registries", builder.Values{
+ "credentials_username": nil,
+ "credentials_password": nil,
+ }).
+ F("WHERE id = ?", evt.ID).
+ Exec(s.db, ctx)
+ case domain.RegistryDeleted:
+ return builder.
+ Command("DELETE FROM registries WHERE id = ?", evt.ID).
+ Exec(s.db, ctx)
+ default:
+ return nil
+ }
+ })
+}