selfprivacy.org.app/lib/logic/providers/server_providers/hetzner.dart
NaiJi ✨ 6819192219 feat: Add country names to installation process (#501)
Fixes #494

Reviewed-on: https://git.selfprivacy.org/SelfPrivacy/selfprivacy.org.app/pulls/501
Reviewed-by: Inex Code <inex.code@selfprivacy.org>
Co-authored-by: NaiJi  <naiji@noreply.git.selfprivacy.org>
Co-committed-by: NaiJi  <naiji@noreply.git.selfprivacy.org>
2024-04-24 12:54:32 +03:00

929 lines
26 KiB
Dart

import 'dart:convert';
import 'package:easy_localization/easy_localization.dart';
import 'package:selfprivacy/logic/api_maps/rest_maps/server_providers/hetzner/hetzner_api.dart';
import 'package:selfprivacy/logic/models/callback_dialogue_branching.dart';
import 'package:selfprivacy/logic/models/disk_size.dart';
import 'package:selfprivacy/logic/models/hive/server_details.dart';
import 'package:selfprivacy/logic/models/json/hetzner_server_info.dart';
import 'package:selfprivacy/logic/models/metrics.dart';
import 'package:selfprivacy/logic/models/price.dart';
import 'package:selfprivacy/logic/models/server_basic_info.dart';
import 'package:selfprivacy/logic/models/server_metadata.dart';
import 'package:selfprivacy/logic/models/server_provider_location.dart';
import 'package:selfprivacy/logic/models/server_type.dart';
import 'package:selfprivacy/logic/providers/server_providers/server_provider.dart';
import 'package:selfprivacy/utils/extensions/string_extensions.dart';
import 'package:selfprivacy/utils/network_utils.dart';
import 'package:selfprivacy/utils/password_generator.dart';
class ApiAdapter {
ApiAdapter({final String? region, final bool isWithToken = true})
: _api = HetznerApi(
region: region,
isWithToken: isWithToken,
);
HetznerApi api({final bool getInitialized = true}) => getInitialized
? _api
: HetznerApi(
region: _api.region,
isWithToken: false,
);
final HetznerApi _api;
}
class HetznerServerProvider extends ServerProvider {
HetznerServerProvider() : _adapter = ApiAdapter();
HetznerServerProvider.load(
final String? location,
final bool isAuthotized,
) : _adapter = ApiAdapter(
isWithToken: isAuthotized,
region: location,
);
ApiAdapter _adapter;
final Currency currency = Currency.fromType(CurrencyType.eur);
int? cachedCoreAmount;
@override
ServerProviderType get type => ServerProviderType.hetzner;
@override
Future<GenericResult<List<ServerBasicInfo>>> getServers() async {
final List<ServerBasicInfo> servers = [];
final result = await _adapter.api().getServers();
if (result.data.isEmpty || !result.success) {
return GenericResult(
success: result.success,
data: servers,
code: result.code,
message: result.message,
);
}
final List<HetznerServerInfo> hetznerServers = result.data;
for (final hetznerServer in hetznerServers) {
if (hetznerServer.publicNet.ipv4 == null) {
continue;
}
ServerBasicInfo? server;
try {
server = ServerBasicInfo(
id: hetznerServer.id,
name: hetznerServer.name,
ip: hetznerServer.publicNet.ipv4!.ip,
reverseDns: hetznerServer.publicNet.ipv4!.reverseDns,
created: hetznerServer.created,
);
} catch (e) {
continue;
}
servers.add(server);
}
return GenericResult(success: true, data: servers);
}
@override
Future<GenericResult<ServerType?>> getServerType(final int serverId) async {
ServerType? serverType;
HetznerServerInfo? server;
final result = await _adapter.api().getServers();
if (result.data.isEmpty || !result.success) {
return GenericResult(
success: result.success,
data: serverType,
code: result.code,
message: result.message,
);
}
final List<HetznerServerInfo> hetznerServers = result.data;
for (final hetznerServer in hetznerServers) {
if (hetznerServer.publicNet.ipv4 != null ||
hetznerServer.id == serverId) {
server = hetznerServer;
break;
}
}
if (server == null) {
const String msg = 'getServerType: no server!';
print(msg);
return GenericResult(
success: false,
data: serverType,
message: msg,
);
}
double? priceValue;
for (final price in server.serverType.prices) {
if (price.location == server.location.name) {
priceValue = price.monthly;
}
}
if (priceValue == null) {
const String msg = 'getServerType: no price!';
print(msg);
return GenericResult(
success: false,
data: serverType,
message: msg,
);
}
return GenericResult(
success: true,
data: ServerType(
title: server.serverType.description,
identifier: server.serverType.name,
ram: server.serverType.memory.toDouble(),
cores: server.serverType.cores,
disk: DiskSize(byte: server.serverType.disk * 1024 * 1024 * 1024),
price: Price(
value: priceValue,
currency: currency,
),
location: ServerProviderLocation(
title: server.location.city,
description: server.location.description,
flag: server.location.flag,
identifier: server.location.name,
countryDisplayKey: server.location.countryDisplayKey,
),
),
);
}
@override
Future<GenericResult<CallbackDialogueBranching?>> launchInstallation(
final LaunchInstallationData installationData,
) async {
final volumeResult = await _adapter.api().createVolume(
installationData.storageSize.gibibyte.toInt(),
);
if (!volumeResult.success || volumeResult.data == null) {
return GenericResult(
data: CallbackDialogueBranching(
choices: [
CallbackDialogueChoice(
title: 'basis.cancel'.tr(),
callback: () async => await installationData.errorCallback(),
),
CallbackDialogueChoice(
title: 'modals.try_again'.tr(),
callback: () async => launchInstallation(installationData),
),
],
description:
volumeResult.message ?? 'modals.volume_creation_error'.tr(),
title: 'modals.unexpected_error'.tr(),
),
success: false,
message: volumeResult.message,
code: volumeResult.code,
);
}
final volume = volumeResult.data!;
final serverApiToken = StringGenerators.apiToken();
final hostname = getHostnameFromDomain(
installationData.serverDomain.domainName,
);
final serverResult = await _adapter.api().createServer(
dnsApiToken: installationData.dnsApiToken,
rootUser: installationData.rootUser,
domainName: installationData.serverDomain.domainName,
serverType: installationData.serverTypeId,
dnsProviderType: installationData.dnsProviderType.toInfectName(),
hostName: hostname,
volumeId: volume.id,
base64Password: base64.encode(
utf8.encode(installationData.rootUser.password ?? 'PASS'),
),
databasePassword: StringGenerators.dbPassword(),
serverApiToken: serverApiToken,
customSshKey: installationData.customSshKey,
);
if (!serverResult.success || serverResult.data == null) {
await _adapter.api().deleteVolume(volume.id);
await Future.delayed(const Duration(seconds: 5));
if (serverResult.message != null &&
serverResult.message == 'uniqueness_error') {
return GenericResult(
data: CallbackDialogueBranching(
choices: [
CallbackDialogueChoice(
title: 'basis.cancel'.tr(),
callback: () async => installationData.errorCallback(),
),
CallbackDialogueChoice(
title: 'modals.yes'.tr(),
callback: () async {
final deleting = await deleteServer(hostname);
if (deleting.success) {
return launchInstallation(installationData);
}
return deleting;
},
),
],
description: 'modals.destroy_server'.tr(),
title: 'modals.already_exists'.tr(),
),
success: false,
message: volumeResult.message,
code: volumeResult.code,
);
} else {
return GenericResult(
data: CallbackDialogueBranching(
choices: [
CallbackDialogueChoice(
title: 'basis.cancel'.tr(),
callback: () async {
final deletion = await deleteServer(hostname);
if (deletion.success) {
installationData.errorCallback();
}
return deletion;
},
),
CallbackDialogueChoice(
title: 'modals.try_again'.tr(),
callback: () async => launchInstallation(installationData),
),
],
description:
volumeResult.message ?? 'recovering.generic_error'.tr(),
title: 'modals.unexpected_error'.tr(),
),
success: false,
message: volumeResult.message,
code: volumeResult.code,
);
}
}
final serverDetails = ServerHostingDetails(
id: serverResult.data!.id,
ip4: serverResult.data!.publicNet.ipv4!.ip,
createTime: DateTime.now(),
volume: ServerProviderVolume(
id: volume.id,
name: volume.name,
sizeByte: volume.size * 1024 * 1024 * 1024,
serverId: volume.serverId,
linuxDevice: volume.linuxDevice,
),
apiToken: serverApiToken,
provider: ServerProviderType.hetzner,
);
cachedCoreAmount = serverResult.data!.serverType.cores;
final createDnsResult = await _adapter.api().createReverseDns(
serverId: serverDetails.id,
ip4: serverDetails.ip4,
dnsPtr: installationData.serverDomain.domainName,
);
if (!createDnsResult.success) {
return GenericResult(
data: CallbackDialogueBranching(
choices: [
CallbackDialogueChoice(
title: 'basis.cancel'.tr(),
callback: () async {
final deletion = await deleteServer(hostname);
if (deletion.success) {
installationData.errorCallback();
}
return deletion;
},
),
CallbackDialogueChoice(
title: 'modals.try_again'.tr(),
callback: () async {
await _adapter.api().deleteVolume(volume.id);
await Future.delayed(const Duration(seconds: 5));
final deletion = await deleteServer(hostname);
if (deletion.success) {
await Future.delayed(const Duration(seconds: 5));
return launchInstallation(installationData);
}
return deletion;
},
),
],
description: volumeResult.message ?? 'recovering.generic_error'.tr(),
title: 'modals.unexpected_error'.tr(),
),
success: false,
message: volumeResult.message,
code: volumeResult.code,
);
}
await installationData.successCallback(serverDetails);
return GenericResult(success: true, data: null);
}
@override
Future<GenericResult<CallbackDialogueBranching?>> deleteServer(
final String hostname,
) async {
final serversResult = await _adapter.api().getServers();
try {
final servers = serversResult.data;
HetznerServerInfo? foundServer;
for (final server in servers) {
if (server.name == hostname) {
foundServer = server;
break;
}
}
for (final volumeId in foundServer!.volumes) {
await _adapter.api().detachVolume(volumeId);
}
await Future.delayed(const Duration(seconds: 10));
final List<Future> laterFutures = <Future>[];
for (final volumeId in foundServer.volumes) {
laterFutures.add(_adapter.api().deleteVolume(volumeId));
}
laterFutures.add(_adapter.api().deleteServer(serverId: foundServer.id));
await Future.wait(laterFutures);
} catch (e) {
print(e);
return GenericResult(
success: false,
data: CallbackDialogueBranching(
choices: [
CallbackDialogueChoice(
title: 'basis.cancel'.tr(),
callback: null,
),
CallbackDialogueChoice(
title: 'modals.try_again'.tr(),
callback: () async {
await Future.delayed(const Duration(seconds: 5));
return deleteServer(hostname);
},
),
],
description: 'modals.try_again'.tr(),
title: 'modals.server_deletion_error'.tr(),
),
message: e.toString(),
);
}
return GenericResult(
success: true,
data: null,
);
}
@override
Future<GenericResult<bool>> tryInitApiByToken(final String token) async {
final api = _adapter.api(getInitialized: false);
final result = await api.isApiTokenValid(token);
if (!result.data || !result.success) {
return result;
}
_adapter = ApiAdapter(region: api.region, isWithToken: true);
return result;
}
@override
Future<GenericResult<bool>> trySetServerLocation(
final String location,
) async {
final bool apiInitialized = _adapter.api().isWithToken;
if (!apiInitialized) {
return GenericResult(
success: true,
data: false,
message: 'Not authorized!',
);
}
_adapter = ApiAdapter(
isWithToken: true,
region: location,
);
return success;
}
@override
Future<GenericResult<List<ServerProviderLocation>>>
getAvailableLocations() async {
final List<ServerProviderLocation> locations = [];
final result = await _adapter.api().getAvailableLocations();
if (result.data.isEmpty || !result.success) {
return GenericResult(
success: result.success,
data: locations,
code: result.code,
message: result.message,
);
}
final List<HetznerLocation> rawLocations = result.data;
for (final rawLocation in rawLocations) {
ServerProviderLocation? location;
try {
location = ServerProviderLocation(
title: rawLocation.city,
description: rawLocation.description,
flag: rawLocation.flag,
identifier: rawLocation.name,
countryDisplayKey: rawLocation.countryDisplayKey,
);
} catch (e) {
continue;
}
locations.add(location);
}
return GenericResult(success: true, data: locations);
}
@override
Future<GenericResult<List<ServerType>>> getServerTypes({
required final ServerProviderLocation location,
}) async {
final List<ServerType> types = [];
final result = await _adapter.api().getAvailableServerTypes();
if (result.data.isEmpty || !result.success) {
return GenericResult(
success: result.success,
data: types,
code: result.code,
message: result.message,
);
}
final rawTypes = result.data;
for (final rawType in rawTypes) {
if (rawType.architecture == 'arm') {
continue;
}
for (final rawPrice in rawType.prices) {
if (rawPrice.location == location.identifier) {
types.add(
ServerType(
title: rawType.description,
identifier: rawType.name,
ram: rawType.memory.toDouble(),
cores: rawType.cores,
disk: DiskSize(byte: rawType.disk * 1024 * 1024 * 1024),
price: Price(
value: rawPrice.monthly,
currency: currency,
),
location: location,
),
);
}
}
}
return GenericResult(success: true, data: types);
}
@override
Future<GenericResult<DateTime?>> powerOn(final int serverId) async {
DateTime? timestamp;
final result = await _adapter.api().powerOn(serverId);
if (!result.success) {
return GenericResult(
success: false,
data: timestamp,
code: result.code,
message: result.message,
);
}
timestamp = DateTime.now();
return GenericResult(
success: true,
data: timestamp,
);
}
@override
Future<GenericResult<DateTime?>> restart(final int serverId) async {
DateTime? timestamp;
final result = await _adapter.api().restart(serverId);
if (!result.success) {
return GenericResult(
success: false,
data: timestamp,
code: result.code,
message: result.message,
);
}
timestamp = DateTime.now();
return GenericResult(
success: true,
data: timestamp,
);
}
@override
Future<GenericResult<AdditionalPricing?>> getAdditionalPricing() async {
final result = await _adapter.api().getPricing();
if (!result.success || result.data == null) {
return GenericResult(
data: null,
success: false,
message: result.message,
code: result.code,
);
}
return GenericResult(
success: true,
data: AdditionalPricing(
perVolumeGb: Price(
value: result.data!.perVolumeGb,
currency: currency,
),
perPublicIpv4: Price(
value: result.data!.perPublicIpv4,
currency: currency,
),
),
);
}
@override
Future<GenericResult<List<ServerProviderVolume>>> getVolumes({
final String? status,
}) async {
final List<ServerProviderVolume> volumes = [];
final result = await _adapter.api().getVolumes();
if (!result.success) {
return GenericResult(
data: [],
success: false,
message: result.message,
code: result.code,
);
}
try {
for (final rawVolume in result.data) {
final int volumeId = rawVolume.id;
final int volumeSize = rawVolume.size * 1024 * 1024 * 1024;
final volumeServer = rawVolume.serverId;
final String volumeName = rawVolume.name;
final volumeDevice = rawVolume.linuxDevice;
final volume = ServerProviderVolume(
id: volumeId,
name: volumeName,
sizeByte: volumeSize,
serverId: volumeServer,
linuxDevice: volumeDevice,
);
volumes.add(volume);
}
} catch (e) {
return GenericResult(
data: [],
success: false,
message: e.toString(),
);
}
return GenericResult(
data: volumes,
success: true,
code: result.code,
message: result.message,
);
}
@override
Future<GenericResult<ServerProviderVolume?>> createVolume(
final int gb,
) async {
ServerProviderVolume? volume;
final result = await _adapter.api().createVolume(gb);
if (!result.success || result.data == null) {
return GenericResult(
data: null,
success: false,
message: result.message,
code: result.code,
);
}
try {
volume = ServerProviderVolume(
id: result.data!.id,
name: result.data!.name,
sizeByte: result.data!.size * 1024 * 1024 * 1024,
serverId: result.data!.serverId,
linuxDevice: result.data!.linuxDevice,
);
} catch (e) {
print(e);
return GenericResult(
data: null,
success: false,
message: e.toString(),
);
}
return GenericResult(
data: volume,
success: true,
code: result.code,
message: result.message,
);
}
@override
Future<GenericResult<void>> deleteVolume(
final ServerProviderVolume volume,
) async =>
_adapter.api().deleteVolume(volume.id);
@override
Future<GenericResult<bool>> resizeVolume(
final ServerProviderVolume volume,
final DiskSize size,
) async =>
_adapter.api().resizeVolume(
HetznerVolume(
volume.id,
volume.sizeByte,
volume.serverId,
volume.name,
volume.linuxDevice,
),
size,
);
@override
Future<GenericResult<bool>> attachVolume(
final ServerProviderVolume volume,
final int serverId,
) async =>
_adapter.api().attachVolume(
HetznerVolume(
volume.id,
volume.sizeByte,
volume.serverId,
volume.name,
volume.linuxDevice,
),
serverId,
);
@override
Future<GenericResult<bool>> detachVolume(
final ServerProviderVolume volume,
) async =>
_adapter.api().detachVolume(
volume.id,
);
@override
Future<GenericResult<List<ServerMetadataEntity>>> getMetadata(
final int serverId,
) async {
List<ServerMetadataEntity> metadata = [];
final resultServers = await _adapter.api().getServers();
if (resultServers.data.isEmpty || !resultServers.success) {
return GenericResult(
success: false,
data: metadata,
code: resultServers.code,
message: resultServers.message,
);
}
final resultVolumes = await _adapter.api().getVolumes();
if (resultVolumes.data.isEmpty || !resultVolumes.success) {
return GenericResult(
success: false,
data: metadata,
code: resultVolumes.code,
message: resultVolumes.message,
);
}
final resultPricePerGb = await getAdditionalPricing();
if (resultPricePerGb.data == null || !resultPricePerGb.success) {
return GenericResult(
success: false,
data: metadata,
code: resultPricePerGb.code,
message: resultPricePerGb.message,
);
}
final List<HetznerServerInfo> servers = resultServers.data;
final List<HetznerVolume> volumes = resultVolumes.data;
try {
final Price pricePerGb = resultPricePerGb.data!.perVolumeGb;
final Price pricePerIp = resultPricePerGb.data!.perPublicIpv4;
final HetznerServerInfo server = servers.firstWhere(
(final server) => server.id == serverId,
);
final HetznerVolume volume = volumes.firstWhere(
(final volume) => server.volumes.contains(volume.id),
);
metadata = [
ServerMetadataEntity(
type: MetadataType.id,
trId: 'server.server_id',
value: server.id.toString(),
),
ServerMetadataEntity(
type: MetadataType.status,
trId: 'server.status',
value: server.status.toString().split('.')[1].capitalize(),
),
ServerMetadataEntity(
type: MetadataType.cpu,
trId: 'server.cpu',
value: server.serverType.cores.toString(),
),
ServerMetadataEntity(
type: MetadataType.ram,
trId: 'server.ram',
value: '${server.serverType.memory.toString()} GB',
),
ServerMetadataEntity(
type: MetadataType.cost,
trId: 'server.monthly_cost',
value:
// TODO: Make more descriptive
'${server.serverType.prices[1].monthly.toStringAsFixed(2)} + ${(volume.size * pricePerGb.value).toStringAsFixed(2)} + ${pricePerIp.value.toStringAsFixed(2)} ${currency.shortcode}',
),
ServerMetadataEntity(
type: MetadataType.location,
trId: 'server.location',
value: '${server.location.city}, ${server.location.country}',
),
ServerMetadataEntity(
type: MetadataType.other,
trId: 'server.server_provider',
value: type.displayName,
),
];
} catch (e) {
return GenericResult(
success: false,
data: [],
message: e.toString(),
);
}
return GenericResult(success: true, data: metadata);
}
@override
Future<GenericResult<ServerMetrics?>> getMetrics(
final int serverId,
final DateTime start,
final DateTime end,
) async {
ServerMetrics? metrics;
List<TimeSeriesData> serializeTimeNetworkSeries(
final Map<String, dynamic> json,
final String type,
) {
final List list = json['time_series'][type]['values'];
return list
.map((final el) => TimeSeriesData(el[0], double.parse(el[1])))
.toList();
}
if (cachedCoreAmount == null) {
final serversResult = await _adapter.api().getServers();
if (serversResult.data.isEmpty || !serversResult.success) {
return GenericResult(
success: false,
data: metrics,
code: serversResult.code,
message: serversResult.message,
);
}
for (final server in serversResult.data) {
if (server.id == serverId) {
cachedCoreAmount = server.serverType.cores;
}
}
if (cachedCoreAmount == null) {
return GenericResult(
success: false,
data: metrics,
message: "Couldn't find active server to cache core amount",
);
}
}
List<TimeSeriesData> serializeTimeCpuSeries(
final Map<String, dynamic> json,
final String type,
) {
final List list = json['time_series'][type]['values'];
return list
.map(
(final el) => TimeSeriesData(
el[0],
double.parse(el[1]) / cachedCoreAmount!,
),
)
.toList();
}
final cpuResult = await _adapter.api().getMetrics(
serverId,
start,
end,
'cpu',
);
if (cpuResult.data.isEmpty || !cpuResult.success) {
return GenericResult(
success: false,
data: metrics,
code: cpuResult.code,
message: cpuResult.message,
);
}
final netResult = await _adapter.api().getMetrics(
serverId,
start,
end,
'network',
);
if (cpuResult.data.isEmpty || !netResult.success) {
return GenericResult(
success: false,
data: metrics,
code: netResult.code,
message: netResult.message,
);
}
metrics = ServerMetrics(
cpu: serializeTimeCpuSeries(
cpuResult.data,
'cpu',
),
bandwidthIn: serializeTimeNetworkSeries(
netResult.data,
'network.0.bandwidth.in',
),
bandwidthOut: serializeTimeNetworkSeries(
netResult.data,
'network.0.bandwidth.out',
),
end: end,
start: start,
stepsInSecond: cpuResult.data['step'],
);
return GenericResult(data: metrics, success: true);
}
}