Skip to content

Commit f879fed

Browse files
committed
feat: add Kubernetes deployment support
Add KubernetesActions deploy backend that manages ExApp lifecycle (deploy, expose, remove) via HaRP's Kubernetes API endpoints. Wire K8s flow into CLI commands (register/unregister daemon and ExApp) and pass kubernetes deploy config from the Vue frontend. Signed-off-by: Oleksander Piskun <oleksandr2088@icloud.com>
1 parent 94dc104 commit f879fed

6 files changed

Lines changed: 822 additions & 7 deletions

File tree

lib/Command/Daemon/RegisterDaemon.php

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,27 @@ protected function configure(): void {
5050
$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');
5151
$this->addOption('harp_exapp_direct', null, InputOption::VALUE_NONE, 'Flag for the advanced setups only. Disables the FRP tunnel between ExApps and HaRP.');
5252

53+
// Kubernetes options
54+
$this->addOption('k8s', null, InputOption::VALUE_NONE, 'Flag to indicate Kubernetes daemon (uses kubernetes-install deploy ID). Requires --harp flag.');
55+
$this->addOption('k8s_expose_type', null, InputOption::VALUE_REQUIRED, 'Kubernetes Service type: nodeport|clusterip|loadbalancer|manual (default: clusterip)', 'clusterip');
56+
$this->addOption('k8s_node_port', null, InputOption::VALUE_REQUIRED, 'Optional NodePort (30000-32767) for nodeport expose type');
57+
$this->addOption('k8s_upstream_host', null, InputOption::VALUE_REQUIRED, 'Override upstream host for HaRP to reach ExApps. Required for manual expose type.');
58+
$this->addOption('k8s_external_traffic_policy', null, InputOption::VALUE_REQUIRED, 'Cluster|Local for NodePort/LoadBalancer Service types');
59+
$this->addOption('k8s_load_balancer_ip', null, InputOption::VALUE_REQUIRED, 'Optional LoadBalancer IP for loadbalancer expose type');
60+
$this->addOption('k8s_node_address_type', null, InputOption::VALUE_REQUIRED, 'InternalIP|ExternalIP for auto node selection (default: InternalIP)', 'InternalIP');
61+
5362
$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');
5463
$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');
5564
$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"');
5665
$this->addUsage('docker_install "Docker Socket Proxy" "docker-install" "http" "nextcloud-appapi-dsp:2375" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
5766
$this->addUsage('manual_install "Manual Install" "manual-install" "http" null "http://nextcloud.local"');
5867
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
5968
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
69+
70+
// Kubernetes usage examples
71+
$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');
72+
$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"');
73+
$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');
6074
}
6175

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

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

99+
// Kubernetes validation
100+
if ($isK8s) {
101+
if (!$isHarp) {
102+
$output->writeln('Value error: Kubernetes daemon (--k8s) requires --harp flag. K8s always uses HaRP.');
103+
return 1;
104+
}
105+
// Override accepts-deploy-id for K8s
106+
if ($acceptsDeployId !== 'kubernetes-install') {
107+
$output->writeln('<comment>Note: --k8s flag detected. Overriding accepts-deploy-id to "kubernetes-install".</comment>');
108+
$acceptsDeployId = 'kubernetes-install';
109+
}
110+
111+
$k8sExposeType = $input->getOption('k8s_expose_type');
112+
$validExposeTypes = ['nodeport', 'clusterip', 'loadbalancer', 'manual'];
113+
if (!in_array($k8sExposeType, $validExposeTypes)) {
114+
$output->writeln(sprintf('Value error: Invalid k8s_expose_type "%s". Must be one of: %s', $k8sExposeType, implode(', ', $validExposeTypes)));
115+
return 1;
116+
}
117+
118+
$k8sNodePort = $input->getOption('k8s_node_port');
119+
if ($k8sNodePort !== null) {
120+
$k8sNodePort = (int)$k8sNodePort;
121+
if ($k8sExposeType !== 'nodeport') {
122+
$output->writeln('Value error: --k8s_node_port is only valid with --k8s_expose_type=nodeport');
123+
return 1;
124+
}
125+
if ($k8sNodePort < 30000 || $k8sNodePort > 32767) {
126+
$output->writeln('Value error: --k8s_node_port must be between 30000 and 32767');
127+
return 1;
128+
}
129+
}
130+
131+
$k8sLoadBalancerIp = $input->getOption('k8s_load_balancer_ip');
132+
if ($k8sLoadBalancerIp !== null && $k8sExposeType !== 'loadbalancer') {
133+
$output->writeln('Value error: --k8s_load_balancer_ip is only valid with --k8s_expose_type=loadbalancer');
134+
return 1;
135+
}
136+
137+
$k8sUpstreamHost = $input->getOption('k8s_upstream_host');
138+
if ($k8sExposeType === 'manual' && $k8sUpstreamHost === null) {
139+
$output->writeln('Value error: --k8s_upstream_host is required for --k8s_expose_type=manual');
140+
return 1;
141+
}
142+
143+
$k8sExternalTrafficPolicy = $input->getOption('k8s_external_traffic_policy');
144+
if ($k8sExternalTrafficPolicy !== null) {
145+
$validPolicies = ['Cluster', 'Local'];
146+
if (!in_array($k8sExternalTrafficPolicy, $validPolicies)) {
147+
$output->writeln(sprintf('Value error: Invalid k8s_external_traffic_policy "%s". Must be one of: %s', $k8sExternalTrafficPolicy, implode(', ', $validPolicies)));
148+
return 1;
149+
}
150+
}
151+
152+
$k8sNodeAddressType = $input->getOption('k8s_node_address_type');
153+
$validNodeAddressTypes = ['InternalIP', 'ExternalIP'];
154+
if (!in_array($k8sNodeAddressType, $validNodeAddressTypes)) {
155+
$output->writeln(sprintf('Value error: Invalid k8s_node_address_type "%s". Must be one of: %s', $k8sNodeAddressType, implode(', ', $validNodeAddressTypes)));
156+
return 1;
157+
}
158+
}
159+
84160
if ($acceptsDeployId === 'manual-install' && !$isHarp && str_contains($host, ':')) {
85161
$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>');
86162
}
@@ -94,18 +170,32 @@ protected function execute(InputInterface $input, OutputInterface $output): int
94170
? $input->getOption('harp_shared_key')
95171
: $input->getOption('haproxy_password') ?? '';
96172

173+
// For K8s, 'net' is not used (K8s has its own networking), default to 'bridge' to avoid validation issues
174+
$defaultNet = $isK8s ? 'bridge' : 'host';
97175
$deployConfig = [
98-
'net' => $input->getOption('net') ?? 'host',
176+
'net' => $input->getOption('net') ?? $defaultNet,
99177
'nextcloud_url' => $nextcloudUrl,
100178
'haproxy_password' => $secret,
101179
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
102180
'harp' => null,
181+
'kubernetes' => null,
103182
];
104183
if ($isHarp) {
105184
$deployConfig['harp'] = [
106185
'frp_address' => $input->getOption('harp_frp_address') ?? '',
107186
'docker_socket_port' => $input->getOption('harp_docker_socket_port'),
108-
'exapp_direct' => (bool)$input->getOption('harp_exapp_direct'),
187+
'exapp_direct' => $isK8s ? true : (bool)$input->getOption('harp_exapp_direct'), // K8s always uses direct (Service-based) routing
188+
];
189+
}
190+
if ($isK8s) {
191+
$k8sNodePort = $input->getOption('k8s_node_port');
192+
$deployConfig['kubernetes'] = [
193+
'expose_type' => $input->getOption('k8s_expose_type') ?? 'clusterip',
194+
'node_port' => $k8sNodePort !== null ? (int)$k8sNodePort : null,
195+
'upstream_host' => $input->getOption('k8s_upstream_host'),
196+
'external_traffic_policy' => $input->getOption('k8s_external_traffic_policy'),
197+
'load_balancer_ip' => $input->getOption('k8s_load_balancer_ip'),
198+
'node_address_type' => $input->getOption('k8s_node_address_type') ?? 'InternalIP',
109199
];
110200
}
111201

lib/Command/ExApp/Register.php

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
use OCA\AppAPI\AppInfo\Application;
1313
use OCA\AppAPI\DeployActions\DockerActions;
14+
use OCA\AppAPI\DeployActions\KubernetesActions;
1415
use OCA\AppAPI\DeployActions\ManualActions;
1516
use OCA\AppAPI\Fetcher\ExAppArchiveFetcher;
1617
use OCA\AppAPI\Service\AppAPIService;
@@ -33,6 +34,7 @@ public function __construct(
3334
private readonly DaemonConfigService $daemonConfigService,
3435
private readonly DockerActions $dockerActions,
3536
private readonly ManualActions $manualActions,
37+
private readonly KubernetesActions $kubernetesActions,
3638
private readonly IAppConfig $appConfig,
3739
private readonly ExAppService $exAppService,
3840
private readonly ISecureRandom $random,
@@ -132,6 +134,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
132134
$actionsDeployIds = [
133135
$this->dockerActions->getAcceptsDeployId(),
134136
$this->manualActions->getAcceptsDeployId(),
137+
$this->kubernetesActions->getAcceptsDeployId(),
135138
];
136139
if (!in_array($daemonConfig->getAcceptsDeployId(), $actionsDeployIds)) {
137140
$this->logger->error(sprintf('Daemon config %s actions for %s not found.', $daemonConfigName, $daemonConfig->getAcceptsDeployId()));
@@ -200,6 +203,46 @@ protected function execute(InputInterface $input, OutputInterface $output): int
200203
(int)explode('=', $deployParams['container_params']['env'][6])[1],
201204
$auth,
202205
);
206+
} elseif ($daemonConfig->getAcceptsDeployId() === $this->kubernetesActions->getAcceptsDeployId()) {
207+
$deployParams = $this->kubernetesActions->buildDeployParams($daemonConfig, $appInfo);
208+
$this->kubernetesActions->initGuzzleClient($daemonConfig);
209+
$deployResult = $this->kubernetesActions->deployExApp($exApp, $daemonConfig, $deployParams);
210+
if ($deployResult) {
211+
$this->logger->error(sprintf('ExApp %s K8s deployment failed. Error: %s', $appId, $deployResult));
212+
if ($outputConsole) {
213+
$output->writeln(sprintf('ExApp %s K8s deployment failed. Error: %s', $appId, $deployResult));
214+
}
215+
$this->exAppService->setStatusError($exApp, $deployResult);
216+
$this->_unregisterExApp($appId, $isTestDeployMode);
217+
return 1;
218+
}
219+
220+
// For K8s, expose the ExApp (create Service) and get upstream endpoint
221+
$k8sConfig = $daemonConfig->getDeployConfig()['kubernetes'] ?? [];
222+
$exposeResult = $this->kubernetesActions->exposeExApp(
223+
$this->kubernetesActions->buildHarpK8sUrl($daemonConfig),
224+
$appId,
225+
(int)$appInfo['port'],
226+
$k8sConfig
227+
);
228+
if (isset($exposeResult['error'])) {
229+
$this->logger->error(sprintf('ExApp %s K8s expose failed. Error: %s', $appId, $exposeResult['error']));
230+
if ($outputConsole) {
231+
$output->writeln(sprintf('ExApp %s K8s expose failed. Error: %s', $appId, $exposeResult['error']));
232+
}
233+
$this->exAppService->setStatusError($exApp, $exposeResult['error']);
234+
$this->_unregisterExApp($appId, $isTestDeployMode);
235+
return 1;
236+
}
237+
238+
$exAppUrl = $this->kubernetesActions->resolveExAppUrl(
239+
$appId,
240+
$daemonConfig->getProtocol(),
241+
$daemonConfig->getHost(),
242+
$daemonConfig->getDeployConfig(),
243+
(int)$appInfo['port'],
244+
$auth,
245+
);
203246
} else {
204247
$this->manualActions->deployExApp($exApp, $daemonConfig);
205248
$exAppUrl = $this->manualActions->resolveExAppUrl(

lib/Command/ExApp/Unregister.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace OCA\AppAPI\Command\ExApp;
1111

1212
use OCA\AppAPI\DeployActions\DockerActions;
13+
use OCA\AppAPI\DeployActions\KubernetesActions;
1314

1415
use OCA\AppAPI\Service\AppAPIService;
1516
use OCA\AppAPI\Service\DaemonConfigService;
@@ -26,6 +27,7 @@ public function __construct(
2627
private readonly AppAPIService $service,
2728
private readonly DaemonConfigService $daemonConfigService,
2829
private readonly DockerActions $dockerActions,
30+
private readonly KubernetesActions $kubernetesActions,
2931
private readonly ExAppService $exAppService,
3032
) {
3133
parent::__construct();
@@ -142,6 +144,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int
142144
}
143145
}
144146
}
147+
} elseif ($daemonConfig->getAcceptsDeployId() === $this->kubernetesActions->getAcceptsDeployId()) {
148+
$this->kubernetesActions->initGuzzleClient($daemonConfig);
149+
$removeResult = $this->kubernetesActions->removeExApp(
150+
$this->kubernetesActions->buildHarpK8sUrl($daemonConfig),
151+
$exApp->getAppid(),
152+
removeData: $rmData
153+
);
154+
if ($removeResult) {
155+
if (!$silent) {
156+
$output->writeln(sprintf('Failed to remove K8s ExApp %s: %s', $appId, $removeResult));
157+
$output->writeln('Hint: If the K8s deployment was already removed manually, use --force to remove from AppAPI.');
158+
}
159+
if (!$force) {
160+
return 1;
161+
}
162+
} elseif (!$silent) {
163+
$output->writeln(sprintf('ExApp %s K8s resources successfully removed', $appId));
164+
}
145165
}
146166

147167
if (!$this->exAppService->unregisterExApp($appId)) {

0 commit comments

Comments
 (0)