feat: use .well-known/yucca.json to discover backend (#126)

This commit is contained in:
Paul Makles
2026-06-18 18:21:09 +01:00
committed by GitHub
parent 27d1ac10c2
commit 65b585a435
12 changed files with 109 additions and 85 deletions
@@ -7,7 +7,6 @@ import {
RepositoryUpdateResponseDto, RepositoryUpdateResponseDto,
} from '@futo-org/backups-api-client'; } from '@futo-org/backups-api-client';
import { BackendType } from '../enum'; import { BackendType } from '../enum';
import { ModuleConfig } from '../moduleConfig';
import { BackendConfiguration } from '../schema/tables/backend.table'; import { BackendConfiguration } from '../schema/tables/backend.table';
export abstract class Backend { export abstract class Backend {
@@ -30,13 +29,10 @@ export abstract class Backend {
abstract submitMetricRepositorySize(id: string, size: number): Promise<void>; abstract submitMetricRepositorySize(id: string, size: number): Promise<void>;
abstract submitStructuredLog(summary: string, data: object): void; abstract submitStructuredLog(summary: string, data: object): void;
static from(configuration: BackendConfiguration, moduleConfig: ModuleConfig) { static from(configuration: BackendConfiguration) {
switch (configuration.type) { switch (configuration.type) {
case BackendType.Yucca: { case BackendType.Yucca: {
return new YuccaBackend({ return new YuccaBackend(configuration);
url: moduleConfig.yuccaProductionApi,
...configuration,
});
} }
case BackendType.Local: { case BackendType.Local: {
return new LocalBackend(configuration); return new LocalBackend(configuration);
@@ -13,18 +13,49 @@ import {
submitStructuredLog, submitStructuredLog,
updateRepository, updateRepository,
} from '@futo-org/backups-api-client'; } from '@futo-org/backups-api-client';
import { YUCCA_WELL_KNOWN } from '../const';
import { BackendType, CookieName } from '../enum'; import { BackendType, CookieName } from '../enum';
import { BackendConfiguration } from '../schema/tables/backend.table'; import { BackendConfiguration } from '../schema/tables/backend.table';
import { Backend } from './backend'; import { Backend } from './backend';
type WellKnown = {
backends: Record<string, { displayName: string; api: string }>;
defaultBackend: string;
};
class YuccaWellKnown {
private data?: WellKnown;
async get() {
this.data ??= await fetch(YUCCA_WELL_KNOWN).then((response) => response.json());
return this.data! as WellKnown;
}
async getBaseUrlById(id: string) {
const { backends } = await this.get();
return backends[id].api;
}
async getBaseUrl() {
const { backends, defaultBackend } = await this.get();
return backends[defaultBackend].api;
}
}
export const yuccaWellKnown = new YuccaWellKnown();
export class YuccaBackend extends Backend { export class YuccaBackend extends Backend {
constructor(protected readonly configuration: BackendConfiguration & { type: BackendType.Yucca; url: string }) { constructor(protected readonly configuration: BackendConfiguration & { type: BackendType.Yucca; url?: string }) {
super(configuration); super(configuration);
} }
private get requestOptions() { private async getRequestOptions() {
return { return {
baseUrl: this.configuration.url, baseUrl:
this.configuration.url ??
(this.configuration.uuid
? await yuccaWellKnown.getBaseUrlById(this.configuration.uuid)
: await yuccaWellKnown.getBaseUrl()),
headers: { headers: {
cookie: `${CookieName.YuccaAccessToken}=${this.configuration.accessToken}`, cookie: `${CookieName.YuccaAccessToken}=${this.configuration.accessToken}`,
}, },
@@ -40,60 +71,56 @@ export class YuccaBackend extends Backend {
} }
async checkOnline() { async checkOnline() {
await getAuth(this.requestOptions); await getAuth(await this.getRequestOptions());
} }
createRepository(dto: RepositoryCreateRequestDto) { async createRepository(dto: RepositoryCreateRequestDto) {
return createRepository(dto, this.requestOptions); return await createRepository(dto, await this.getRequestOptions());
} }
updateRepository(id: string, dto: RepositoryUpdateRequestDto) { async updateRepository(id: string, dto: RepositoryUpdateRequestDto) {
return updateRepository(id, dto, this.requestOptions); return await updateRepository(id, dto, await this.getRequestOptions());
} }
getRepository(id: string) { async getRepository(id: string) {
return getRepository(id, this.requestOptions); return await getRepository(id, await this.getRequestOptions());
} }
getRepositories() { async getRepositories() {
return getRepositories(this.requestOptions); return await getRepositories(await this.getRequestOptions());
} }
deleteRepository(id: string) { async deleteRepository(id: string) {
return deleteRepository(id, this.requestOptions); return await deleteRepository(id, await this.getRequestOptions());
} }
async getResticEndpoint(id: string) { async getResticEndpoint(id: string) {
const { url } = await createResticUrl(id, this.requestOptions); const { url } = await createResticUrl(id, await this.getRequestOptions());
return url; return url;
} }
submitMetricBackupStart(id: string): Promise<void> { async submitMetricBackupStart(id: string): Promise<void> {
return submitMetricBackupStart(id, this.requestOptions); return await submitMetricBackupStart(id, await this.getRequestOptions());
} }
submitMetricBackupEnd(id: string, success: boolean, durationMs: number): Promise<void> { async submitMetricBackupEnd(id: string, success: boolean, durationMs: number): Promise<void> {
return submitMetricBackupEnd( return await submitMetricBackupEnd(
id, id,
{ {
durationMs, durationMs,
success, success,
}, },
this.requestOptions, await this.getRequestOptions(),
); );
} }
submitMetricRepositorySize(id: string, sizeBytes: number): Promise<void> { async submitMetricRepositorySize(id: string, sizeBytes: number): Promise<void> {
return submitMetricRepositorySize(id, { sizeBytes }, this.requestOptions); return await submitMetricRepositorySize(id, { sizeBytes }, await this.getRequestOptions());
} }
submitStructuredLog(summary: string, data: object) { submitStructuredLog(summary: string, data: object) {
void submitStructuredLog( void this.getRequestOptions()
{ .then((requestOptions) => submitStructuredLog({ summary, data }, requestOptions))
summary, .catch((error) => console.error('Failed to submit log', error));
data,
},
this.requestOptions,
).catch((error) => console.error('Failed to submit log', error));
} }
} }
@@ -1,2 +1,3 @@
export const ORCHESTRATION_PORT = 22_676; export const ORCHESTRATION_PORT = 22_676;
export const YUCCA_PRODUCTION_UUID = 'd0368cdd-39ae-40e1-91d6-d81815c65c7e'; export const REPOSITORY_DEFAULT_CLOUD_UUID = 'd0368cdd-39ae-40e1-91d6-d81815c65c7e';
export const YUCCA_WELL_KNOWN = 'https://futo.cloud/.well-known/yucca.json';
@@ -13,7 +13,7 @@ export type ImmichIntegration = {
export type ModuleConfig = { export type ModuleConfig = {
statePath: string; statePath: string;
yuccaProductionApi: string; yuccaProductionApi?: string;
externalBaseUrl?: string; externalBaseUrl?: string;
requireWsAuth?: boolean; requireWsAuth?: boolean;
requireLock?: boolean; requireLock?: boolean;
@@ -95,13 +95,9 @@ export const services = [
@Module({}) @Module({})
export class OrchestrationApiModule { export class OrchestrationApiModule {
static forRoot(config: Partial<ModuleConfig> & Pick<ModuleConfig, 'yuccaProductionApi'>): DynamicModule { static forRoot(config: Partial<ModuleConfig>): DynamicModule {
config.statePath ??= resolve(homedir(), '.yucca'); config.statePath ??= resolve(homedir(), '.yucca');
if (!config.yuccaProductionApi) {
throw new Error('config.yuccaProductionApi is missing');
}
return { return {
module: OrchestrationApiModule, module: OrchestrationApiModule,
imports: [ imports: [
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Backend } from '../backends/backend';
import { DB } from '../schema'; import { DB } from '../schema';
import { BackendConfiguration } from '../schema/tables/backend.table'; import { BackendConfiguration } from '../schema/tables/backend.table';
@@ -22,10 +23,12 @@ export class BackendRepository {
async getBackends() { async getBackends() {
const backends = await this.db.selectFrom('backends').selectAll('backends').execute(); const backends = await this.db.selectFrom('backends').selectAll('backends').execute();
return backends.map(({ id, configuration }) => ({ return backends
id, .map(({ id, configuration }) => ({
configuration: JSON.parse(configuration) as BackendConfiguration, id,
})); configuration: JSON.parse(configuration) as BackendConfiguration,
}))
.map(({ id, configuration }) => ({ id, configuration, backend: Backend.from(configuration) }));
} }
async getBackend(id: string) { async getBackend(id: string) {
@@ -35,9 +38,12 @@ export class BackendRepository {
.where('id', '=', id) .where('id', '=', id)
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
const configuration = JSON.parse(backend.configuration) as BackendConfiguration;
return { return {
id: backend.id, id: backend.id,
configuration: JSON.parse(backend.configuration) as BackendConfiguration, configuration,
backend: Backend.from(configuration),
}; };
} }
} }
@@ -11,6 +11,7 @@ export type BackendConfiguration =
*/ */
type: BackendType.Yucca; type: BackendType.Yucca;
url?: string; url?: string;
uuid?: string;
accessToken: string; accessToken: string;
} }
| { | {
@@ -1,6 +1,7 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { createEventSource, EventSourceClient } from 'eventsource-client'; import { createEventSource, EventSourceClient } from 'eventsource-client';
import { YUCCA_PRODUCTION_UUID } from '../const'; import { yuccaWellKnown } from '../backends/yucca.backend';
import { REPOSITORY_DEFAULT_CLOUD_UUID } from '../const';
import { BackendType } from '../enum'; import { BackendType } from '../enum';
import { EventsGateway } from '../events/events.gateway'; import { EventsGateway } from '../events/events.gateway';
import { BackendRepository } from '../repositories/backend.repository'; import { BackendRepository } from '../repositories/backend.repository';
@@ -18,25 +19,26 @@ export class AuthService {
readonly telemetry: TelemetryService, readonly telemetry: TelemetryService,
) {} ) {}
private async waitForDeviceFlow(events: EventSourceClient) { private async waitForDeviceFlow(events: EventSourceClient, url?: string) {
for await (const { data } of events) { for await (const { data } of events) {
const { type, accessToken } = JSON.parse(data); const { type, accessToken } = JSON.parse(data);
switch (type) { switch (type) {
case 'SUCCESS': { case 'SUCCESS': {
await this.backend.updateBackend(YUCCA_PRODUCTION_UUID, { await this.backend.updateBackend(REPOSITORY_DEFAULT_CLOUD_UUID, {
type: BackendType.Yucca, type: BackendType.Yucca,
accessToken, accessToken,
url,
}); });
this.telemetry.submitStructuredLog('Connected FUTO Backups backend', { this.telemetry.submitStructuredLog('Connected FUTO Backups backend', {
backendId: YUCCA_PRODUCTION_UUID, backendId: REPOSITORY_DEFAULT_CLOUD_UUID,
}); });
this.events.publish({ this.events.publish({
type: 'BackendCreate', type: 'BackendCreate',
backend: { backend: {
id: YUCCA_PRODUCTION_UUID, id: REPOSITORY_DEFAULT_CLOUD_UUID,
type: BackendType.Yucca, type: BackendType.Yucca,
description: 'FUTO Backups', description: 'FUTO Backups',
isOnline: true, isOnline: true,
@@ -61,15 +63,18 @@ export class AuthService {
} }
async oidcDeviceFlow(): Promise<{ userCode: string; verificationUri: string }> { async oidcDeviceFlow(): Promise<{ userCode: string; verificationUri: string }> {
const overrideEndpoint = this.moduleConfig.get().yuccaProductionApi;
const endpoint = overrideEndpoint ?? (await yuccaWellKnown.getBaseUrl());
const events: EventSourceClient = createEventSource({ const events: EventSourceClient = createEventSource({
url: new URL('/api/auth/oidc/device', this.moduleConfig.get().yuccaProductionApi), url: new URL('/api/auth/oidc/device', endpoint),
onDisconnect: () => events.close(), onDisconnect: () => events.close(),
}); });
for await (const { data } of events) { for await (const { data } of events) {
const { userCode, verificationUri } = JSON.parse(data); const { userCode, verificationUri } = JSON.parse(data);
void this.waitForDeviceFlow(events).catch(() => {}); void this.waitForDeviceFlow(events, overrideEndpoint).catch(() => {});
return { return {
userCode, userCode,
@@ -5,13 +5,11 @@ import { BackendResponseDto, BackendsResponseDto, CreateLocalBackendRequestDto }
import { BackendType } from '../enum'; import { BackendType } from '../enum';
import { EventsGateway } from '../events/events.gateway'; import { EventsGateway } from '../events/events.gateway';
import { BackendRepository } from '../repositories/backend.repository'; import { BackendRepository } from '../repositories/backend.repository';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
import { TelemetryService } from './telemetry.service'; import { TelemetryService } from './telemetry.service';
@Injectable() @Injectable()
export class BackendService { export class BackendService {
constructor( constructor(
private readonly moduleConfig: ModuleConfigRepository,
private readonly repository: BackendRepository, private readonly repository: BackendRepository,
private readonly events: EventsGateway, private readonly events: EventsGateway,
private readonly telemetry: TelemetryService, private readonly telemetry: TelemetryService,
@@ -22,7 +20,7 @@ export class BackendService {
const error = await Promise.all( const error = await Promise.all(
backends.map((backend) => backends.map((backend) =>
Backend.from(backend.configuration, this.moduleConfig.get()) Backend.from(backend.configuration)
.checkOnline() .checkOnline()
.then(() => void 0) .then(() => void 0)
.catch((error) => error), .catch((error) => error),
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
import { type WriteStream } from 'node:fs'; import { type WriteStream } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { Backend } from '../backends/backend';
import { FilesystemListingRequestDto, FilesystemListingResponseDto } from '../dto/filesystem.dto'; import { FilesystemListingRequestDto, FilesystemListingResponseDto } from '../dto/filesystem.dto';
import { import {
ListSnapshotsResponseDto, ListSnapshotsResponseDto,
@@ -86,9 +85,7 @@ export class RepositoryService {
backendId = backends[0].id; backendId = backends[0].id;
} }
const { configuration } = await this.backend.getBackend(backendId); const { backend, configuration } = await this.backend.getBackend(backendId);
const backend = Backend.from(configuration, this.moduleConfig.get());
const { repository: remote } = await backend.createRepository(dto); const { repository: remote } = await backend.createRepository(dto);
const id = randomUUID(); const id = randomUUID();
@@ -144,8 +141,7 @@ export class RepositoryService {
const backendsById = Object.fromEntries(backends.map((backend) => [backend.id, backend])); const backendsById = Object.fromEntries(backends.map((backend) => [backend.id, backend]));
const remoteRepositories: Record<string, Record<string, RepositoryWithMetricsDto>> = {}; const remoteRepositories: Record<string, Record<string, RepositoryWithMetricsDto>> = {};
for (const { id: backendId, configuration } of backends) { for (const { id: backendId, backend } of backends) {
const backend = Backend.from(configuration, this.moduleConfig.get());
remoteRepositories[backendId] = {}; remoteRepositories[backendId] = {};
try { try {
@@ -289,14 +285,13 @@ export class RepositoryService {
remoteId = localRepository.remoteId; remoteId = localRepository.remoteId;
} }
const backend = await this.backend.getBackend(backendId); const { backend, configuration } = await this.backend.getBackend(backendId);
const backendInstance = Backend.from(backend.configuration, this.moduleConfig.get());
let remote; let remote;
if (dto.name) { if (dto.name) {
({ repository: remote } = await backendInstance.updateRepository(remoteId, dto)); ({ repository: remote } = await backend.updateRepository(remoteId, dto));
} else { } else {
({ repository: remote } = await backendInstance.getRepository(remoteId)); ({ repository: remote } = await backend.getRepository(remoteId));
} }
if (dto.paths) { if (dto.paths) {
@@ -330,7 +325,7 @@ export class RepositoryService {
backends: { backends: {
primary: { primary: {
id: backendId, id: backendId,
type: backend.configuration.type, type: configuration.type,
online: true, online: true,
}, },
secondary: [], secondary: [],
@@ -382,9 +377,8 @@ export class RepositoryService {
({ backendId, remoteId } = repository); ({ backendId, remoteId } = repository);
} }
const backend = await this.backend.getBackend(backendId); const { backend } = await this.backend.getBackend(backendId);
const backendInstance = Backend.from(backend.configuration, this.moduleConfig.get()); const endpoint = await backend.getResticEndpoint(remoteId);
const endpoint = await backendInstance.getResticEndpoint(remoteId);
const key = await this.config.deriveEncryptionKey(`repository-${remoteId}`); const key = await this.config.deriveEncryptionKey(`repository-${remoteId}`);
@@ -424,8 +418,7 @@ export class RepositoryService {
if (metrics.sizeBytes) { if (metrics.sizeBytes) {
const { backendId, remoteId } = await this.repository.get(id); const { backendId, remoteId } = await this.repository.get(id);
const { configuration } = await this.backend.getBackend(backendId); const { backend } = await this.backend.getBackend(backendId);
const backend = Backend.from(configuration, this.moduleConfig.get());
if (backend.isMetricsCapable()) { if (backend.isMetricsCapable()) {
await backend.submitMetricRepositorySize(remoteId, metrics.sizeBytes); await backend.submitMetricRepositorySize(remoteId, metrics.sizeBytes);
@@ -458,9 +451,7 @@ export class RepositoryService {
}); });
const { backendId, remoteId } = await this.repository.get(id); const { backendId, remoteId } = await this.repository.get(id);
const { configuration } = await this.backend.getBackend(backendId); const { backend } = await this.backend.getBackend(backendId);
const backend = Backend.from(configuration, this.moduleConfig.get());
const { endpoint, key } = await this.getResticParameters(id); const { endpoint, key } = await this.getResticParameters(id);
const paths = await this.repositoryPath.get(id); const paths = await this.repositoryPath.get(id);
@@ -667,8 +658,7 @@ export class RepositoryService {
}); });
try { try {
const { configuration } = await this.backend.getBackend(backendId); const { configuration, backend } = await this.backend.getBackend(backendId);
const backend = Backend.from(configuration, this.moduleConfig.get());
const { repository: remote } = await backend.getRepository(id); const { repository: remote } = await backend.getRepository(id);
const localId = randomUUID(); const localId = randomUUID();
@@ -734,8 +724,7 @@ export class RepositoryService {
id: string, id: string,
dto: RepositoryPrimaryBackendReconfigureRequestDto, dto: RepositoryPrimaryBackendReconfigureRequestDto,
): Promise<RepositoryCreateResponseDto> { ): Promise<RepositoryCreateResponseDto> {
const { configuration } = await this.backend.getBackend(dto.backendId); const { backend, configuration } = await this.backend.getBackend(dto.backendId);
const backend = Backend.from(configuration, this.moduleConfig.get());
const { repository: remote } = await backend.createRepository({ const { repository: remote } = await backend.createRepository({
name: 'Restored Repository', name: 'Restored Repository',
@@ -1,9 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Backend } from '../backends/backend'; import { Backend } from '../backends/backend';
import { YUCCA_PRODUCTION_UUID } from '../const'; import { REPOSITORY_DEFAULT_CLOUD_UUID } from '../const';
import { BackendRepository } from '../repositories/backend.repository'; import { BackendRepository } from '../repositories/backend.repository';
import { ConfigRepository } from '../repositories/config.repository'; import { ConfigRepository } from '../repositories/config.repository';
import { ModuleConfigRepository } from '../repositories/moduleConfig.repository';
function serializeErrors(data: object): object { function serializeErrors(data: object): object {
return Object.fromEntries( return Object.fromEntries(
@@ -22,7 +21,6 @@ export class TelemetryService {
constructor( constructor(
private readonly config: ConfigRepository, private readonly config: ConfigRepository,
private readonly backend: BackendRepository, private readonly backend: BackendRepository,
private readonly moduleConfig: ModuleConfigRepository,
) {} ) {}
private async submitStructuredLogImpl(summary: string, data: object, force: boolean) { private async submitStructuredLogImpl(summary: string, data: object, force: boolean) {
@@ -34,9 +32,9 @@ export class TelemetryService {
} }
void this.backend void this.backend
.getBackend(YUCCA_PRODUCTION_UUID) .getBackend(REPOSITORY_DEFAULT_CLOUD_UUID)
.then(({ configuration }) => { .then(({ configuration }) => {
const backend = Backend.from(configuration, this.moduleConfig.get()); const backend = Backend.from(configuration);
backend.submitStructuredLog(summary, { backend.submitStructuredLog(summary, {
...serializeErrors(data), ...serializeErrors(data),
version, version,
@@ -21,4 +21,11 @@ records = {
values = ["10.10.10.90", "10.10.10.91", "10.10.10.92"] values = ["10.10.10.90", "10.10.10.91", "10.10.10.92"]
comment = "Sietch RGW S3 virtual-hosted buckets (tf/deployment/dev/dns)" comment = "Sietch RGW S3 virtual-hosted buckets (tf/deployment/dev/dns)"
} }
# move me to somewhere sensible!
"futo.cloud" = {
type = "A"
values = ["45.144.160.215"] # homelab
proxied = true
comment = "Website (just serving .well-known/yucca.json)"
}
} }