Skip to content
Draft
2 changes: 1 addition & 1 deletion js/app_api-adminSettings.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/app_api-adminSettings.js.map

Large diffs are not rendered by default.

93 changes: 91 additions & 2 deletions lib/Command/Daemon/RegisterDaemon.php
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,27 @@ protected function configure(): void {
$this->addOption('harp_docker_socket_port', null, InputOption::VALUE_REQUIRED, '\'remotePort\' of the FRP client of the remote Docker socket proxy. There is one included in the harp container so this can be skipped for default setups.', '24000');
$this->addOption('harp_exapp_direct', null, InputOption::VALUE_NONE, 'Flag for the advanced setups only. Disables the FRP tunnel between ExApps and HaRP.');

// Kubernetes options
$this->addOption('k8s', null, InputOption::VALUE_NONE, 'Flag to indicate Kubernetes daemon (uses kubernetes-install deploy ID). Requires --harp flag.');
$this->addOption('k8s_expose_type', null, InputOption::VALUE_REQUIRED, 'Kubernetes Service type: nodeport|clusterip|loadbalancer|manual (default: clusterip)', 'clusterip');
$this->addOption('k8s_node_port', null, InputOption::VALUE_REQUIRED, 'Optional NodePort (30000-32767) for nodeport expose type');
$this->addOption('k8s_upstream_host', null, InputOption::VALUE_REQUIRED, 'Override upstream host for HaRP to reach ExApps. Required for manual expose type.');
$this->addOption('k8s_external_traffic_policy', null, InputOption::VALUE_REQUIRED, 'Cluster|Local for NodePort/LoadBalancer Service types');
$this->addOption('k8s_load_balancer_ip', null, InputOption::VALUE_REQUIRED, 'Optional LoadBalancer IP for loadbalancer expose type');
$this->addOption('k8s_node_address_type', null, InputOption::VALUE_REQUIRED, 'InternalIP|ExternalIP for auto node selection (default: InternalIP)', 'InternalIP');

$this->addUsage('harp_proxy_docker "Harp Proxy (Docker)" "docker-install" "http" "appapi-harp:8780" "http://nextcloud.local" --net nextcloud --harp --harp_frp_address "appapi-harp:8782" --harp_shared_key "some_very_secure_password" --set-default --compute_device=cuda');
$this->addUsage('harp_proxy_host "Harp Proxy (Host)" "docker-install" "http" "localhost:8780" "http://nextcloud.local" --harp --harp_frp_address "localhost:8782" --harp_shared_key "some_very_secure_password" --set-default --compute_device=cuda');
$this->addUsage('manual_install_harp "Harp Manual Install" "manual-install" "http" "appapi-harp:8780" "http://nextcloud.local" --net nextcloud --harp --harp_frp_address "appapi-harp:8782" --harp_shared_key "some_very_secure_password"');
$this->addUsage('docker_install "Docker Socket Proxy" "docker-install" "http" "nextcloud-appapi-dsp:2375" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
$this->addUsage('manual_install "Manual Install" "manual-install" "http" null "http://nextcloud.local"');
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');

// Kubernetes usage examples
$this->addUsage('k8s_daemon "Kubernetes HaRP" "kubernetes-install" "http" "harp.nextcloud.svc:8780" "http://nextcloud.local" --harp --harp_shared_key "secret" --harp_frp_address "harp.nextcloud.svc:8782" --k8s');
$this->addUsage('k8s_daemon_nodeport "K8s NodePort" "kubernetes-install" "http" "harp.example.com:8780" "http://nextcloud.local" --harp --harp_shared_key "secret" --harp_frp_address "harp.example.com:8782" --k8s --k8s_expose_type=nodeport --k8s_upstream_host="k8s-node.example.com"');
$this->addUsage('k8s_daemon_lb "K8s LoadBalancer" "kubernetes-install" "http" "harp.example.com:8780" "http://nextcloud.local" --harp --harp_shared_key "secret" --harp_frp_address "harp.example.com:8782" --k8s --k8s_expose_type=loadbalancer');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
Expand All @@ -67,6 +81,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$host = $input->getArgument('host');
$nextcloudUrl = $input->getArgument('nextcloud_url');
$isHarp = $input->getOption('harp');
$isK8s = $input->getOption('k8s');

if (($protocol !== 'http') && ($protocol !== 'https')) {
$output->writeln('Value error: The protocol must be `http` or `https`.');
Expand All @@ -81,6 +96,67 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 1;
}

// Kubernetes validation
if ($isK8s) {
if (!$isHarp) {
$output->writeln('Value error: Kubernetes daemon (--k8s) requires --harp flag. K8s always uses HaRP.');
return 1;
}
// Override accepts-deploy-id for K8s
if ($acceptsDeployId !== 'kubernetes-install') {
$output->writeln('<comment>Note: --k8s flag detected. Overriding accepts-deploy-id to "kubernetes-install".</comment>');
$acceptsDeployId = 'kubernetes-install';
}

$k8sExposeType = $input->getOption('k8s_expose_type');
$validExposeTypes = ['nodeport', 'clusterip', 'loadbalancer', 'manual'];
if (!in_array($k8sExposeType, $validExposeTypes)) {
$output->writeln(sprintf('Value error: Invalid k8s_expose_type "%s". Must be one of: %s', $k8sExposeType, implode(', ', $validExposeTypes)));
return 1;
}

$k8sNodePort = $input->getOption('k8s_node_port');
if ($k8sNodePort !== null) {
$k8sNodePort = (int)$k8sNodePort;
if ($k8sExposeType !== 'nodeport') {
$output->writeln('Value error: --k8s_node_port is only valid with --k8s_expose_type=nodeport');
return 1;
}
if ($k8sNodePort < 30000 || $k8sNodePort > 32767) {
$output->writeln('Value error: --k8s_node_port must be between 30000 and 32767');
return 1;
}
}

$k8sLoadBalancerIp = $input->getOption('k8s_load_balancer_ip');
if ($k8sLoadBalancerIp !== null && $k8sExposeType !== 'loadbalancer') {
$output->writeln('Value error: --k8s_load_balancer_ip is only valid with --k8s_expose_type=loadbalancer');
return 1;
}

$k8sUpstreamHost = $input->getOption('k8s_upstream_host');
if ($k8sExposeType === 'manual' && $k8sUpstreamHost === null) {
$output->writeln('Value error: --k8s_upstream_host is required for --k8s_expose_type=manual');
return 1;
}

$k8sExternalTrafficPolicy = $input->getOption('k8s_external_traffic_policy');
if ($k8sExternalTrafficPolicy !== null) {
$validPolicies = ['Cluster', 'Local'];
if (!in_array($k8sExternalTrafficPolicy, $validPolicies)) {
$output->writeln(sprintf('Value error: Invalid k8s_external_traffic_policy "%s". Must be one of: %s', $k8sExternalTrafficPolicy, implode(', ', $validPolicies)));
return 1;
}
}

$k8sNodeAddressType = $input->getOption('k8s_node_address_type');
$validNodeAddressTypes = ['InternalIP', 'ExternalIP'];
if (!in_array($k8sNodeAddressType, $validNodeAddressTypes)) {
$output->writeln(sprintf('Value error: Invalid k8s_node_address_type "%s". Must be one of: %s', $k8sNodeAddressType, implode(', ', $validNodeAddressTypes)));
return 1;
}
}

if ($acceptsDeployId === 'manual-install' && !$isHarp && str_contains($host, ':')) {
$output->writeln('<comment>Warning: The host contains a port, which will be ignored for manual-install daemons. The ExApp\'s port from --json-info will be used instead.</comment>');
}
Expand All @@ -94,18 +170,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int
? $input->getOption('harp_shared_key')
: $input->getOption('haproxy_password') ?? '';

$defaultNet = $isK8s ? 'bridge' : 'host';
$deployConfig = [
'net' => $input->getOption('net') ?? 'host',
'net' => $input->getOption('net') ?? $defaultNet,
'nextcloud_url' => $nextcloudUrl,
'haproxy_password' => $secret,
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
'harp' => null,
'kubernetes' => null,
];
if ($isHarp) {
$deployConfig['harp'] = [
'frp_address' => $input->getOption('harp_frp_address') ?? '',
'docker_socket_port' => $input->getOption('harp_docker_socket_port'),
'exapp_direct' => (bool)$input->getOption('harp_exapp_direct'),
'exapp_direct' => $isK8s ? true : (bool)$input->getOption('harp_exapp_direct'),
];
}
if ($isK8s) {
$k8sNodePort = $input->getOption('k8s_node_port');
$deployConfig['kubernetes'] = [
'expose_type' => $input->getOption('k8s_expose_type') ?? 'clusterip',
'node_port' => $k8sNodePort !== null ? (int)$k8sNodePort : null,
'upstream_host' => $input->getOption('k8s_upstream_host'),
'external_traffic_policy' => $input->getOption('k8s_external_traffic_policy'),
'load_balancer_ip' => $input->getOption('k8s_load_balancer_ip'),
'node_address_type' => $input->getOption('k8s_node_address_type') ?? 'InternalIP',
];
}

Expand Down
56 changes: 56 additions & 0 deletions lib/Command/ExApp/Register.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\DeployActions\DockerActions;
use OCA\AppAPI\DeployActions\KubernetesActions;
use OCA\AppAPI\DeployActions\ManualActions;
use OCA\AppAPI\Fetcher\ExAppArchiveFetcher;
use OCA\AppAPI\Service\AppAPIService;
Expand All @@ -33,6 +34,7 @@ public function __construct(
private readonly DaemonConfigService $daemonConfigService,
private readonly DockerActions $dockerActions,
private readonly ManualActions $manualActions,
private readonly KubernetesActions $kubernetesActions,
private readonly IAppConfig $appConfig,
private readonly ExAppService $exAppService,
private readonly ISecureRandom $random,
Expand Down Expand Up @@ -132,6 +134,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$actionsDeployIds = [
$this->dockerActions->getAcceptsDeployId(),
$this->manualActions->getAcceptsDeployId(),
$this->kubernetesActions->getAcceptsDeployId(),
];
if (!in_array($daemonConfig->getAcceptsDeployId(), $actionsDeployIds)) {
$this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfigName, $daemonConfig->getAcceptsDeployId()));
Expand Down Expand Up @@ -166,6 +169,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}

$auth = [];
$harpK8sUrl = null;
$k8sRoles = [];
if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) {
$deployParams = $this->dockerActions->buildDeployParams($daemonConfig, $appInfo);
if (boolval($exApp->getDeployConfig()['harp'] ?? false)) {
Expand Down Expand Up @@ -200,6 +205,53 @@ protected function execute(InputInterface $input, OutputInterface $output): int
(int)explode('=', $deployParams['container_params']['env'][6])[1],
$auth,
);
} elseif ($daemonConfig->getAcceptsDeployId() === $this->kubernetesActions->getAcceptsDeployId()) {
$deployParams = $this->kubernetesActions->buildDeployParams($daemonConfig, $appInfo);
$this->kubernetesActions->initGuzzleClient($daemonConfig);
$harpK8sUrl = $this->kubernetesActions->buildHarpK8sUrl($daemonConfig);
$k8sRoles = $deployParams['k8s_service_roles'] ?? [];
$deployResult = $this->kubernetesActions->deployExApp($exApp, $daemonConfig, $deployParams);
if ($deployResult) {
$this->logger->error(sprintf('ExApp %s K8s deployment failed. Error: %s', $appId, $deployResult));
if ($outputConsole) {
$output->writeln(sprintf('ExApp %s K8s deployment failed. Error: %s', $appId, $deployResult));
}
$this->exAppService->setStatusError($exApp, $deployResult);
$this->kubernetesActions->cleanupResources($harpK8sUrl, $appId, $k8sRoles);
$this->_unregisterExApp($appId, $isTestDeployMode);
return 1;
}

// For K8s, expose the ExApp (create Service) and get upstream endpoint
$k8sConfig = $daemonConfig->getDeployConfig()['kubernetes'] ?? [];
if (!empty($k8sRoles)) {
$exposeResult = $this->kubernetesActions->exposeExAppRoles(
$harpK8sUrl, $appId, (int)$appInfo['port'], $k8sConfig, $k8sRoles
);
} else {
$exposeResult = $this->kubernetesActions->exposeExApp(
$harpK8sUrl, $appId, (int)$appInfo['port'], $k8sConfig
);
}
if (isset($exposeResult['error'])) {
$this->logger->error(sprintf('ExApp %s K8s expose failed. Error: %s', $appId, $exposeResult['error']));
if ($outputConsole) {
$output->writeln(sprintf('ExApp %s K8s expose failed. Error: %s', $appId, $exposeResult['error']));
}
$this->exAppService->setStatusError($exApp, $exposeResult['error']);
$this->kubernetesActions->cleanupResources($harpK8sUrl, $appId, $k8sRoles);
$this->_unregisterExApp($appId, $isTestDeployMode);
return 1;
}

$exAppUrl = $this->kubernetesActions->resolveExAppUrl(
$appId,
$daemonConfig->getProtocol(),
$daemonConfig->getHost(),
$daemonConfig->getDeployConfig(),
(int)$appInfo['port'],
$auth,
);
} else {
$this->manualActions->deployExApp($exApp, $daemonConfig);
$exAppUrl = $this->manualActions->resolveExAppUrl(
Expand All @@ -218,6 +270,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$output->writeln(sprintf('ExApp %s heartbeat check failed. Make sure that Nextcloud instance and ExApp can reach it other.', $appId));
}
$this->exAppService->setStatusError($exApp, 'Heartbeat check failed');
if ($harpK8sUrl !== null) {
$this->kubernetesActions->cleanupResources($harpK8sUrl, $appId, $k8sRoles);
$this->_unregisterExApp($appId, $isTestDeployMode);
}
return 1;
}
$this->logger->info(sprintf('ExApp %s deployed successfully.', $appId));
Expand Down
102 changes: 68 additions & 34 deletions lib/Command/ExApp/Unregister.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@
namespace OCA\AppAPI\Command\ExApp;

use OCA\AppAPI\DeployActions\DockerActions;
use OCA\AppAPI\DeployActions\KubernetesActions;

use OCA\AppAPI\Service\AppAPIService;
use OCA\AppAPI\Service\DaemonConfigService;
use OCA\AppAPI\Service\ExAppDeployOptionsService;
use OCA\AppAPI\Service\ExAppService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
Expand All @@ -26,7 +28,9 @@ public function __construct(
private readonly AppAPIService $service,
private readonly DaemonConfigService $daemonConfigService,
private readonly DockerActions $dockerActions,
private readonly KubernetesActions $kubernetesActions,
private readonly ExAppService $exAppService,
private readonly ExAppDeployOptionsService $exAppDeployOptionsService,
) {
parent::__construct();
}
Expand Down Expand Up @@ -95,51 +99,81 @@ protected function execute(InputInterface $input, OutputInterface $output): int
return 1;
}
}
if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) {
$this->dockerActions->initGuzzleClient($daemonConfig);

if (boolval($exApp->getDeployConfig()['harp'] ?? false)) {
if ($this->dockerActions->removeExApp($this->dockerActions->buildDockerUrl($daemonConfig), $exApp->getAppid(), removeData: $rmData)) {
if (!$silent) {
$output->writeln(sprintf('Failed to remove ExApp %s', $appId));
$output->writeln('Hint: If the container was already removed manually, you can use the --force option to fully remove it from AppAPI.');
}
if (!$force) {
return 1;
if ($daemonConfig !== null) {
if ($daemonConfig->getAcceptsDeployId() === $this->dockerActions->getAcceptsDeployId()) {
$this->dockerActions->initGuzzleClient($daemonConfig);

if (boolval($exApp->getDeployConfig()['harp'] ?? false)) {
if ($this->dockerActions->removeExApp($this->dockerActions->buildDockerUrl($daemonConfig), $exApp->getAppid(), removeData: $rmData)) {
if (!$silent) {
$output->writeln(sprintf('Failed to remove ExApp %s', $appId));
$output->writeln('Hint: If the container was already removed manually, you can use the --force option to fully remove it from AppAPI.');
}
if (!$force) {
return 1;
}
} else {
if (!$silent) {
$output->writeln(sprintf('ExApp %s successfully removed', $appId));
}
}
} else {
if (!$silent) {
$output->writeln(sprintf('ExApp %s successfully removed', $appId));
$containerName = $this->dockerActions->buildExAppContainerName($appId);
$removeResult = $this->dockerActions->removeContainer(
$this->dockerActions->buildDockerUrl($daemonConfig), $containerName
);
if ($removeResult) {
if (!$silent) {
$output->writeln(sprintf('Failed to remove ExApp %s container', $appId));
$output->writeln(sprintf('Hint: If the container "%s" was already removed manually, you can use the --force option to fully remove it from AppAPI.', $containerName));
}
if (!$force) {
return 1;
}
} elseif (!$silent) {
$output->writeln(sprintf('ExApp %s container successfully removed', $appId));
}
if ($rmData) {
$volumeName = $this->dockerActions->buildExAppVolumeName($appId);
$removeVolumeResult = $this->dockerActions->removeVolume(
$this->dockerActions->buildDockerUrl($daemonConfig), $volumeName
);
if (!$silent) {
if (isset($removeVolumeResult['error'])) {
$output->writeln(sprintf('Failed to remove ExApp %s volume: %s', $appId, $volumeName));
} else {
$output->writeln(sprintf('ExApp %s data volume successfully removed', $appId));
}
}
}
}
} else {
$containerName = $this->dockerActions->buildExAppContainerName($appId);
$removeResult = $this->dockerActions->removeContainer(
$this->dockerActions->buildDockerUrl($daemonConfig), $containerName
);
} elseif ($daemonConfig->getAcceptsDeployId() === $this->kubernetesActions->getAcceptsDeployId()) {
$this->kubernetesActions->initGuzzleClient($daemonConfig);
$harpK8sUrl = $this->kubernetesActions->buildHarpK8sUrl($daemonConfig);

// Check for stored multi-role configuration
$rolesOption = $this->exAppDeployOptionsService->getDeployOption($exApp->getAppid(), 'k8s_service_roles');
$roles = $rolesOption !== null ? $rolesOption->getValue() : [];

if (!empty($roles) && is_array($roles)) {
$removeResult = $this->kubernetesActions->removeAllRoles(
$harpK8sUrl, $exApp->getAppid(), $roles, removeData: $rmData
);
} else {
$removeResult = $this->kubernetesActions->removeExApp(
$harpK8sUrl, $exApp->getAppid(), removeData: $rmData
);
}
if ($removeResult) {
if (!$silent) {
$output->writeln(sprintf('Failed to remove ExApp %s container', $appId));
$output->writeln(sprintf('Hint: If the container "%s" was already removed manually, you can use the --force option to fully remove it from AppAPI.', $containerName));
$output->writeln(sprintf('Failed to remove K8s ExApp %s: %s', $appId, $removeResult));
$output->writeln('Hint: If the K8s deployment was already removed manually, use --force to remove from AppAPI.');
}
if (!$force) {
return 1;
}
} elseif (!$silent) {
$output->writeln(sprintf('ExApp %s container successfully removed', $appId));
}
if ($rmData) {
$volumeName = $this->dockerActions->buildExAppVolumeName($appId);
$removeVolumeResult = $this->dockerActions->removeVolume(
$this->dockerActions->buildDockerUrl($daemonConfig), $volumeName
);
if (!$silent) {
if (isset($removeVolumeResult['error'])) {
$output->writeln(sprintf('Failed to remove ExApp %s volume: %s', $appId, $volumeName));
} else {
$output->writeln(sprintf('ExApp %s data volume successfully removed', $appId));
}
}
$output->writeln(sprintf('ExApp %s K8s resources successfully removed', $appId));
}
}
}
Expand Down
Loading