From f86c68fc6a6f1a40fa1d0a8ddaeaca16281c2658 Mon Sep 17 00:00:00 2001 From: net909 Date: Fri, 10 Oct 2025 20:10:12 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EK8S=E9=83=A8=E7=BD=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/DeployHelper.php | 50 ++++++++++ app/lib/deploy/k8s.php | 202 ++++++++++++++++++++++++++++++++++++++ app/lib/deploy/ucloud.php | 34 ++----- 3 files changed, 262 insertions(+), 24 deletions(-) create mode 100644 app/lib/deploy/k8s.php diff --git a/app/lib/DeployHelper.php b/app/lib/DeployHelper.php index f8b3b06..4e072e0 100644 --- a/app/lib/DeployHelper.php +++ b/app/lib/DeployHelper.php @@ -935,6 +935,56 @@ ctrl+x 保存退出', ], ], ], + 'k8s' => [ + 'name' => 'K8S', + 'class' => 1, + 'icon' => 'server.png', + 'desc' => '部署到K8S集群的Secret和Ingress', + 'note' => '支持部署到K8S集群的Secret和Ingress', + 'tasknote' => '', + 'inputs' => [ + 'name' => [ + 'name' => '名称', + 'type' => 'input', + 'placeholder' => '仅用于区分', + 'required' => true, + ], + 'kubeconfig' => [ + 'name' => 'kubeconfig', + 'type' => 'textarea', + 'placeholder' => '', + 'required' => true, + ], + 'proxy' => [ + 'name' => '使用代理服务器', + 'type' => 'radio', + 'options' => [ + '0' => '否', + '1' => '是', + ], + 'value' => '0' + ], + ], + 'taskinputs' => [ + 'namespace' => [ + 'name' => '命名空间', + 'type' => 'input', + 'value' => 'default', + 'required' => true, + ], + 'secret_name' => [ + 'name' => 'Secret名称', + 'type' => 'input', + 'placeholder' => '如果Secret不存在,则自动创建', + 'required' => true, + ], + 'ingresses' => [ + 'name' => 'Ingress名称', + 'type' => 'input', + 'placeholder' => '多个用英文逗号分隔,可留空,留空则只更新Secret', + ], + ], + ], 'aliyun' => [ 'name' => '阿里云', 'class' => 2, diff --git a/app/lib/deploy/k8s.php b/app/lib/deploy/k8s.php new file mode 100644 index 0000000..c69d2bc --- /dev/null +++ b/app/lib/deploy/k8s.php @@ -0,0 +1,202 @@ +kubeconfig = $config['kubeconfig']; + $this->proxy = $config['proxy'] == 1; + } + + public function check() + { + if (empty($this->kubeconfig)) throw new Exception('Kubeconfig不能为空'); + $this->verify(); + } + + public function deploy($fullchain, $privatekey, $config, &$info) + { + $namespace = $config['namespace']; + $secretName = $config['secret_name']; + if (empty($namespace)) throw new Exception('命名空间不能为空'); + if (empty($secretName)) throw new Exception('Secret名称不能为空'); + + $this->parse(); + + $secretPayload = [ + 'apiVersion' => 'v1', + 'kind' => 'Secret', + 'metadata' => ['name' => $secretName, 'namespace' => $namespace], + 'type' => 'kubernetes.io/tls', + 'data' => [ + 'tls.crt' => base64_encode($config['fullchain']), + 'tls.key' => base64_encode($config['privatekey']), + ], + ]; + + $secretUrl = '/api/v1/namespaces/' . $namespace . '/secrets/' . $secretName; + list($sCode, $sBody, $sErr) = $this->k8s_request('GET', $secretUrl); + + if ($sCode === 404) { + $createUrl = '/api/v1/namespaces/' . $namespace . '/secrets'; + $this->log('Secret:' . $secretName . ' 不存在,正在创建...'); + list($cCode, $cBody, $cErr) = $this->k8s_request('POST', $createUrl, json_encode($secretPayload)); + if ($cCode < 200 || $cCode >= 300) throw new Exception("创建Secret失败 (HTTP $cCode): $cBody | $cErr"); + $this->log('Secret:' . $namespace . ' 创建成功'); + } elseif ($sCode >= 200 && $sCode < 300) { + $this->log('Secret:' . $secretName . ' 已存在,正在更新...'); + $patch = ['data' => $secretPayload['data'], 'type' => 'kubernetes.io/tls']; + list($pCode, $pBody, $pErr) = $this->k8s_request('PATCH', $secretUrl, json_encode($patch)); + if ($pCode < 200 || $pCode >= 300) throw new Exception("更新Secret失败 (HTTP $pCode): $pBody | $pErr"); + $this->log('Secret:' . $secretName . ' 更新成功'); + } else { + throw new Exception("获取Secret失败 (HTTP $sCode): $sBody | $sErr"); + } + + // Bind Secret to specified Ingresses (merge spec.tls & hosts) ---- + if (!empty($config['ingresses'])) { + $ingressUrl = '/apis/networking.k8s.io/v1/namespaces/' . $namespace . '/ingresses'; + foreach (explode(',', $config['ingresses']) as $ingName) { + list($gCode, $gBody, $gErr) = $this->k8s_request('GET', $ingressUrl . '/' . $ingName); + if ($gCode < 200 || $gCode >= 300) throw new Exception("获取Ingress '$ingName' 失败 (HTTP $gCode): $gBody | $gErr"); + $ing = json_decode($gBody, true); + if (!$ing) throw new Exception("解析Ingress '$ingName' JSON失败: $gBody"); + + // collect hosts from spec.rules + $hosts = []; + foreach (($ing['spec']['rules'] ?? []) as $rule) { + if (!empty($rule['host'])) $hosts[] = $rule['host']; + } + $hosts = array_values(array_unique($hosts)); + + // merge/ensure spec.tls entry + $tls = $ing['spec']['tls'] ?? []; + $found = false; + foreach ($tls as &$entry) { + if (($entry['secretName'] ?? '') === $secretName) { + $found = true; + $existingHosts = $entry['hosts'] ?? []; + $entry['hosts'] = array_values(array_unique(array_merge($existingHosts, $hosts))); + } + } + unset($entry); + if (!$found) { + $tls[] = ['secretName' => $secretName, 'hosts' => $hosts]; + } + + $patch = ['spec' => ['tls' => $tls]]; + list($iCode, $iBody, $iErr) = $this->k8s_request('PATCH', $ingressUrl . '/' . $ingName, json_encode($patch, JSON_UNESCAPED_SLASHES)); + if ($iCode < 200 || $iCode >= 300) throw new Exception("更新Ingress '$ingName' 失败 (HTTP $iCode): $iBody | $iErr"); + $this->log("Ingress '$ingName' 更新TLS成功"); + } + } + } + + private function parse() + { + $kcfg = Yaml::parse($this->kubeconfig); + if (!$kcfg) throw new Exception('Kubeconfig格式错误'); + $curr = $kcfg['current-context'] ?? null; + if (!$curr) throw new Exception('Kubeconfig缺少current-context'); + + $contexts = $this->index_by_name($kcfg['contexts'] ?? []); + $clusters = $this->index_by_name($kcfg['clusters'] ?? []); + $users = $this->index_by_name($kcfg['users'] ?? []); + + $ctx = $contexts[$curr] ?? null; + if (!$ctx) throw new Exception("Kubeconfig中找不到current-context: $curr"); + + $clusterName = $ctx['context']['cluster'] ?? null; + $userName = $ctx['context']['user'] ?? null; + if (!$clusterName || !$userName) throw new Exception("Kubeconfig中context缺少cluster或user: $curr"); + + $cluster = $clusters[$clusterName] ?? null; + $user = $users[$userName] ?? null; + if (!$cluster) throw new Exception("Kubeconfig中找不到cluster: $clusterName"); + if (!$user) throw new Exception("Kubeconfig中找不到user: $userName"); + + $this->server = $cluster['cluster']['server'] ?? null; + if (!$this->server) throw new Exception("Kubeconfig中找不到cluster.server"); + $this->server = rtrim($this->server, '/'); + + $this->bearerToken = $user['user']['token'] ?? ($user['user']['auth-provider']['config']['access-token'] ?? null); + $clientCertFile = $clientKeyFile = null; + if (!empty($user['user']['client-certificate-data']) && !empty($user['user']['client-key-data'])) { + $clientCertFile = tempnam(sys_get_temp_dir(), 'kcc_'); + $clientKeyFile = tempnam(sys_get_temp_dir(), 'kck_'); + file_put_contents($clientCertFile, base64_decode($user['user']['client-certificate-data'])); + file_put_contents($clientKeyFile, base64_decode($user['user']['client-key-data'])); + } elseif (!empty($user['user']['client-certificate']) && !empty($user['user']['client-key'])) { + $clientCertFile = $user['user']['client-certificate']; + $clientKeyFile = $user['user']['client-key']; + } + $this->tls = ['cert' => $clientCertFile, 'key' => $clientKeyFile]; + } + + private function verify() + { + $this->parse(); + list($vCode, $vBody, $vErr) = $this->k8s_request('GET', '/version'); + if ($vErr) throw new Exception("连接Kubernetes API服务器失败: $vErr"); + if ($vCode != 200) throw new Exception("连接Kubernetes API服务器失败: HTTP $vCode $vBody"); + } + + private function k8s_request($method, $path, $body = null) + { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $this->server . $path); + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 30); + $headers = ['Accept: application/json']; + if ($this->bearerToken) $headers[] = 'Authorization: Bearer ' . $this->bearerToken; + if ($body !== null) $headers[] = 'Content-Type: application/json'; + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, $body); + curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); + curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); + if (!empty($this->tls['cert']) && !empty($this->tls['key'])) { + curl_setopt($ch, CURLOPT_SSLCERT, $this->tls['cert']); + curl_setopt($ch, CURLOPT_SSLKEY, $this->tls['key']); + } + $resp = curl_exec($ch); + $code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + $err = curl_error($ch); + curl_close($ch); + return [$code, $resp, $err]; + } + + private function index_by_name($arr) + { + $out = []; + foreach ($arr as $item) { + if (isset($item['name'])) $out[$item['name']] = $item; + } + return $out; + } + + public function setLogger($func) + { + $this->logger = $func; + } + + private function log($txt) + { + if ($this->logger) { + call_user_func($this->logger, $txt); + } + } +} diff --git a/app/lib/deploy/ucloud.php b/app/lib/deploy/ucloud.php index f2db275..1b8766b 100644 --- a/app/lib/deploy/ucloud.php +++ b/app/lib/deploy/ucloud.php @@ -37,34 +37,20 @@ class ucloud implements DeployInterface if (!$certInfo) throw new Exception('证书解析失败'); $cert_name = str_replace(['*', '.'], '', $certInfo['subject']['CN']) . '-' . $certInfo['validFrom_time_t']; + $param = [ + 'CertName' => $cert_name, + 'UserCert' => $fullchain, + 'PrivateKey' => $privatekey, + ]; try { - $data = $this->client->request('GetCertificateV2', []); + $data = $this->client->request('AddCertificate', $param); + $this->log('添加证书成功,名称:' . $cert_name); } catch (Exception $e) { - throw new Exception('获取证书列表失败 ' . $e->getMessage()); - } - - $exist = false; - foreach ($data['CertList'] as $cert) { - if (trim($cert['UserCert']) == trim($fullchain)) { - $cert_name = $cert['CertName']; - $exist = true; - } - } - - if (!$exist) { - $param = [ - 'CertName' => $cert_name, - 'UserCert' => $fullchain, - 'PrivateKey' => $privatekey, - ]; - try { - $data = $this->client->request('AddCertificate', $param); - } catch (Exception $e) { + if (strpos($e->getMessage(), 'cert already exist') !== false) { + $this->log('证书已存在,名称:' . $cert_name); + } else { throw new Exception('添加证书失败 ' . $e->getMessage()); } - $this->log('添加证书成功,名称:' . $cert_name); - } else { - $this->log('获取到已添加的证书,名称:' . $cert_name); } try {