refactor: use git-controlled dev ci runner

This commit is contained in:
Codex
2026-05-18 08:38:17 +00:00
parent e2c8daede7
commit f86a75791b
22 changed files with 529 additions and 1402 deletions
@@ -1,20 +0,0 @@
ARG DEVOPS_GO_IMAGE=golang:1.23-bookworm
ARG DEVOPS_RUNTIME_IMAGE=debian:bookworm-slim
FROM ${DEVOPS_GO_IMAGE} AS builder
WORKDIR /src
COPY src/components/microservices/devops/go.mod ./go.mod
COPY src/components/microservices/devops/main.go ./main.go
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/unidesk-devops ./main.go
FROM ${DEVOPS_RUNTIME_IMAGE}
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
COPY --from=builder /out/unidesk-devops /usr/local/bin/unidesk-devops
EXPOSE 4286
CMD ["/usr/local/bin/unidesk-devops"]
@@ -1,3 +0,0 @@
module github.com/pikasTech/unidesk/src/components/microservices/devops
go 1.23
-739
View File
@@ -1,739 +0,0 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
type jsonMap map[string]any
type config struct {
Host string
Port int
Namespace string
RepoURL string
DesiredRef string
Environment string
GitProxyURL string
PipelineName string
PipelineServiceAccount string
WorkspaceClaim string
AppImage string
LogFile string
}
type deployService struct {
ID string `json:"id"`
Repo string `json:"repo"`
CommitID string `json:"commitId"`
}
type deploySummary struct {
DeployCommit string `json:"deployCommit"`
DesiredRef string `json:"desiredRef"`
Environment string `json:"environment"`
RepoURL string `json:"repoUrl"`
Services []deployService `json:"services"`
}
type httpError struct {
Status int
Msg string
Detail jsonMap
}
func (e *httpError) Error() string {
return e.Msg
}
var (
startedAt = time.Now().UTC().Format(time.RFC3339)
recentLogs []jsonMap
serviceConfig = readConfig()
refPattern = regexp.MustCompile(`^[A-Za-z0-9._/-]{1,160}$`)
runIDPattern = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]{0,46}[a-z0-9])?$`)
commitIDPattern = regexp.MustCompile(`^[0-9a-f]{7,40}$`)
)
func envString(name, fallback string) string {
value := os.Getenv(name)
if value == "" {
return fallback
}
return value
}
func envInt(name string, fallback int) int {
raw := os.Getenv(name)
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
return value
}
func currentNamespace() string {
raw, err := os.ReadFile("/var/run/secrets/kubernetes.io/serviceaccount/namespace")
if err != nil {
return "unidesk-ci"
}
value := strings.TrimSpace(string(raw))
if value == "" {
return "unidesk-ci"
}
return value
}
func readConfig() config {
return config{
Host: envString("HOST", "0.0.0.0"),
Port: envInt("PORT", 4286),
Namespace: envString("DEVOPS_NAMESPACE", currentNamespace()),
RepoURL: envString("DEVOPS_REPO_URL", "https://github.com/pikasTech/unidesk"),
DesiredRef: envString("DEVOPS_DESIRED_REF", "master"),
Environment: envString("DEVOPS_ENVIRONMENT", "dev"),
GitProxyURL: envString("DEVOPS_GIT_PROXY_URL", "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"),
PipelineName: envString("DEVOPS_DEV_E2E_PIPELINE", "unidesk-dev-namespace-e2e"),
PipelineServiceAccount: envString("DEVOPS_PIPELINE_SERVICE_ACCOUNT", "unidesk-ci-runner"),
WorkspaceClaim: envString("DEVOPS_PIPELINE_WORKSPACE_CLAIM", "unidesk-ci-cache"),
AppImage: envString("DEVOPS_DEV_E2E_APP_IMAGE", "unidesk-code-queue:dev"),
LogFile: envString("LOG_FILE", "/var/log/unidesk/devops.jsonl"),
}
}
func appendLog(level, event string, detail jsonMap) {
record := jsonMap{"at": time.Now().UTC().Format(time.RFC3339), "service": "devops", "level": level, "event": event}
for key, value := range detail {
record[key] = value
}
recentLogs = append(recentLogs, record)
if len(recentLogs) > 300 {
recentLogs = recentLogs[len(recentLogs)-300:]
}
line, _ := json.Marshal(record)
log.Println(string(line))
if serviceConfig.LogFile != "" {
_ = os.MkdirAll(filepath.Dir(serviceConfig.LogFile), 0o755)
if file, err := os.OpenFile(serviceConfig.LogFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644); err == nil {
_, _ = file.Write(append(line, '\n'))
_ = file.Close()
}
}
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("content-type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func errorBody(err error) jsonMap {
body := jsonMap{"ok": false, "error": err.Error()}
var he *httpError
if errors.As(err, &he) {
for key, value := range he.Detail {
body[key] = value
}
}
return body
}
func handleError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
var he *httpError
if errors.As(err, &he) {
status = he.Status
}
appendLog(map[bool]string{true: "error", false: "warn"}[status >= 500], "request_failed", jsonMap{"status": status, "error": err.Error()})
writeJSON(w, status, errorBody(err))
}
func readJSON(r *http.Request) (jsonMap, error) {
if r.Body == nil {
return jsonMap{}, nil
}
defer r.Body.Close()
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
return nil, err
}
if len(strings.TrimSpace(string(body))) == 0 {
return jsonMap{}, nil
}
var record jsonMap
if err := json.Unmarshal(body, &record); err != nil {
return nil, &httpError{Status: http.StatusBadRequest, Msg: "request body must be JSON"}
}
return record, nil
}
func stringValue(value any) string {
text, _ := value.(string)
return text
}
func boolValue(value any) bool {
switch item := value.(type) {
case bool:
return item
case string:
return item == "true" || item == "1"
case float64:
return item == 1
default:
return false
}
}
func requireRepoURL(value any) (string, error) {
repo := stringValue(value)
if repo == "" {
repo = serviceConfig.RepoURL
}
parsed, err := url.Parse(repo)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" {
return "", &httpError{Status: http.StatusBadRequest, Msg: "repoUrl must be an https URL"}
}
return repo, nil
}
func requireDesiredRef(value any) (string, error) {
ref := stringValue(value)
if ref == "" {
ref = serviceConfig.DesiredRef
}
if !refPattern.MatchString(ref) || strings.HasPrefix(ref, "-") || strings.Contains(ref, "..") {
return "", &httpError{Status: http.StatusBadRequest, Msg: "desired ref contains unsupported characters"}
}
return ref, nil
}
func optionalRunID(value any) (string, error) {
runID := stringValue(value)
if runID == "" {
return "", nil
}
if !runIDPattern.MatchString(runID) {
return "", &httpError{Status: http.StatusBadRequest, Msg: "runId must be DNS-safe lowercase alnum/dash, max 48 chars"}
}
return runID, nil
}
func gitEnv() []string {
env := os.Environ()
noProxy := "localhost,127.0.0.1,::1,d601-provider-egress-proxy,d601-provider-egress-proxy.unidesk,d601-provider-egress-proxy.unidesk.svc,d601-provider-egress-proxy.unidesk.svc.cluster.local"
add := map[string]string{
"HTTP_PROXY": serviceConfig.GitProxyURL,
"HTTPS_PROXY": serviceConfig.GitProxyURL,
"ALL_PROXY": serviceConfig.GitProxyURL,
"NO_PROXY": noProxy,
"http_proxy": serviceConfig.GitProxyURL,
"https_proxy": serviceConfig.GitProxyURL,
"all_proxy": serviceConfig.GitProxyURL,
"no_proxy": noProxy,
}
for key, value := range add {
env = append(env, key+"="+value)
}
return env
}
func runCommand(ctx context.Context, cwd string, args ...string) (string, string, error) {
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
cmd.Dir = cwd
cmd.Env = gitEnv()
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
return stdout.String(), stderr.String(), err
}
func resolveDeployManifest(repoURL, desiredRef, environment string) (deploySummary, error) {
dir, err := os.MkdirTemp("", "unidesk-devops-deploy-")
if err != nil {
return deploySummary{}, err
}
defer os.RemoveAll(dir)
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second)
defer cancel()
if _, stderr, err := runCommand(ctx, dir, "git", "init", "-q"); err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "git init failed", Detail: jsonMap{"stderr": tail(stderr, 2000)}}
}
if _, stderr, err := runCommand(ctx, dir, "git", "remote", "add", "origin", repoURL); err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "git remote add failed", Detail: jsonMap{"stderr": tail(stderr, 2000)}}
}
if _, stderr, err := runCommand(ctx, dir, "git", "fetch", "--depth=1", "origin", desiredRef); err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to fetch desired ref", Detail: jsonMap{"stderr": tail(stderr, 4000)}}
}
stdout, stderr, err := runCommand(ctx, dir, "git", "rev-parse", "FETCH_HEAD")
if err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to resolve desired ref commit", Detail: jsonMap{"stderr": tail(stderr, 2000)}}
}
deployCommit := strings.TrimSpace(stdout)
stdout, stderr, err = runCommand(ctx, dir, "git", "show", "FETCH_HEAD:deploy.json")
if err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadGateway, Msg: "failed to read deploy.json from desired ref", Detail: jsonMap{"stderr": tail(stderr, 4000)}}
}
return parseDeployManifest(stdout, repoURL, desiredRef, environment, deployCommit)
}
func parseDeployManifest(raw, repoURL, desiredRef, environment, deployCommit string) (deploySummary, error) {
var parsed struct {
SchemaVersion int `json:"schemaVersion"`
Environments map[string]struct {
Services []deployService `json:"services"`
} `json:"environments"`
}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must be valid JSON"}
}
if parsed.SchemaVersion != 2 {
return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must use schemaVersion=2"}
}
env, ok := parsed.Environments[environment]
if !ok {
return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json must contain requested environment", Detail: jsonMap{"environment": environment}}
}
if len(env.Services) == 0 {
return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: "deploy.json environment must contain services", Detail: jsonMap{"environment": environment}}
}
for index, service := range env.Services {
service.CommitID = strings.ToLower(service.CommitID)
env.Services[index].CommitID = service.CommitID
if service.ID == "" || service.Repo == "" || !commitIDPattern.MatchString(service.CommitID) {
return deploySummary{}, &httpError{Status: http.StatusBadRequest, Msg: fmt.Sprintf("deploy.json environments.%s.services[%d] must contain id, repo and 7-40 char commitId", environment, index)}
}
}
return deploySummary{DeployCommit: deployCommit, DesiredRef: desiredRef, Environment: environment, RepoURL: repoURL, Services: env.Services}, nil
}
func tail(value string, max int) string {
if len(value) <= max {
return value
}
return value[len(value)-max:]
}
func randomSuffix() string {
var bytes [4]byte
if _, err := rand.Read(bytes[:]); err != nil {
return strconv.FormatInt(time.Now().UnixNano(), 36)
}
return hex.EncodeToString(bytes[:])
}
func makeRunID(deployCommit string) string {
stamp := time.Now().UTC().Format("20060102150405")
runID := fmt.Sprintf("%s-%s", stamp, strings.ToLower(deployCommit[:min(len(deployCommit), 8)]))
runID = regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(runID, "-")
if len(runID) > 48 {
return runID[:48]
}
return runID
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func pipelineRunBody(summary deploySummary, runID string, keepNamespace bool) jsonMap {
return jsonMap{
"apiVersion": "tekton.dev/v1",
"kind": "PipelineRun",
"metadata": jsonMap{
"generateName": fmt.Sprintf("unidesk-dev-e2e-%s-", runID),
"namespace": serviceConfig.Namespace,
"labels": jsonMap{
"app.kubernetes.io/name": "unidesk-dev-namespace-e2e",
"app.kubernetes.io/part-of": "unidesk",
"unidesk.ai/ci-kind": "dev-namespace-e2e",
"unidesk.ai/deploy-ref": "master-deploy-json-dev",
"unidesk.ai/deploy-commit": summary.DeployCommit[:min(len(summary.DeployCommit), 40)],
},
},
"spec": jsonMap{
"pipelineRef": jsonMap{"name": serviceConfig.PipelineName},
"taskRunTemplate": jsonMap{"serviceAccountName": serviceConfig.PipelineServiceAccount},
"params": []jsonMap{
{"name": "repo-url", "value": summary.RepoURL},
{"name": "desired-ref", "value": summary.DesiredRef},
{"name": "deploy-commit", "value": summary.DeployCommit},
{"name": "environment", "value": summary.Environment},
{"name": "run-id", "value": runID},
{"name": "keep-namespace", "value": map[bool]string{true: "true", false: "false"}[keepNamespace]},
{"name": "app-image", "value": serviceConfig.AppImage},
},
"workspaces": []jsonMap{
{"name": "shared-workspace", "persistentVolumeClaim": jsonMap{"claimName": serviceConfig.WorkspaceClaim}},
},
},
}
}
func serviceAccountFile(name string) string {
return filepath.Join("/var/run/secrets/kubernetes.io/serviceaccount", name)
}
func kubeClient() (*http.Client, error) {
caPEM, err := os.ReadFile(serviceAccountFile("ca.crt"))
if err != nil {
return nil, err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, errors.New("failed to load service-account CA")
}
return &http.Client{Timeout: 120 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}, nil
}
func kubeURL(path string) string {
host := envString("KUBERNETES_SERVICE_HOST", "kubernetes.default.svc")
port := envString("KUBERNETES_SERVICE_PORT_HTTPS", envString("KUBERNETES_SERVICE_PORT", "443"))
return fmt.Sprintf("https://%s:%s%s", host, port, path)
}
func kubeRequest(method, path string, body any) (int, []byte, error) {
client, err := kubeClient()
if err != nil {
return 0, nil, err
}
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
return 0, nil, err
}
reader = bytes.NewReader(raw)
}
req, err := http.NewRequest(method, kubeURL(path), reader)
if err != nil {
return 0, nil, err
}
token, err := os.ReadFile(serviceAccountFile("token"))
if err != nil {
return 0, nil, err
}
req.Header.Set("authorization", "Bearer "+strings.TrimSpace(string(token)))
req.Header.Set("accept", "application/json")
if body != nil {
req.Header.Set("content-type", "application/json")
}
res, err := client.Do(req)
if err != nil {
return 0, nil, err
}
defer res.Body.Close()
raw, err := io.ReadAll(io.LimitReader(res.Body, 8*1024*1024))
if err != nil {
return res.StatusCode, nil, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return res.StatusCode, raw, &httpError{Status: res.StatusCode, Msg: "kubernetes api request failed", Detail: jsonMap{"path": path, "status": res.StatusCode, "body": tail(string(raw), 4000)}}
}
return res.StatusCode, raw, nil
}
func metadataName(value any) string {
record, ok := value.(map[string]any)
if !ok {
return ""
}
metadata, ok := record["metadata"].(map[string]any)
if !ok {
return ""
}
return stringValue(metadata["name"])
}
func conditionSummary(value any) jsonMap {
record, ok := value.(map[string]any)
if !ok {
return jsonMap{"terminal": false, "status": "Unknown", "reason": "", "message": ""}
}
status, _ := record["status"].(map[string]any)
conditions, _ := status["conditions"].([]any)
for _, item := range conditions {
condition, ok := item.(map[string]any)
if !ok || condition["type"] != "Succeeded" {
continue
}
statusText := stringValue(condition["status"])
return jsonMap{
"terminal": statusText == "True" || statusText == "False",
"succeeded": statusText == "True",
"status": statusText,
"reason": stringValue(condition["reason"]),
"message": tail(stringValue(condition["message"]), 2000),
}
}
return jsonMap{"terminal": false, "status": "Unknown", "reason": "", "message": ""}
}
func decodeJSON(raw []byte) any {
var value any
if err := json.Unmarshal(raw, &value); err != nil {
return jsonMap{"text": tail(string(raw), 4000)}
}
return value
}
func listItems(value any) []any {
record, ok := value.(map[string]any)
if !ok {
return nil
}
items, _ := record["items"].([]any)
return items
}
func compactTaskRun(value any) jsonMap {
record, _ := value.(map[string]any)
status, _ := record["status"].(map[string]any)
return jsonMap{
"name": metadataName(value),
"condition": conditionSummary(value),
"podName": stringValue(status["podName"]),
"startTime": stringValue(status["startTime"]),
"completionTime": stringValue(status["completionTime"]),
}
}
func compactPod(value any) jsonMap {
record, _ := value.(map[string]any)
status, _ := record["status"].(map[string]any)
spec, _ := record["spec"].(map[string]any)
return jsonMap{
"name": metadataName(value),
"phase": stringValue(status["phase"]),
"nodeName": stringValue(spec["nodeName"]),
"podIP": stringValue(status["podIP"]),
"reason": stringValue(status["reason"]),
}
}
func runStatus(name string) (jsonMap, error) {
namespace := url.PathEscape(serviceConfig.Namespace)
selector := url.QueryEscape("tekton.dev/pipelineRun=" + name)
_, pipelineRaw, err := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns/%s", namespace, url.PathEscape(name)), nil)
if err != nil {
return nil, err
}
_, taskRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/taskruns?labelSelector=%s", namespace, selector), nil)
_, podRaw, _ := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods?labelSelector=%s", namespace, selector), nil)
taskRuns := []jsonMap{}
for _, item := range listItems(decodeJSON(taskRaw)) {
taskRuns = append(taskRuns, compactTaskRun(item))
}
pods := []jsonMap{}
for _, item := range listItems(decodeJSON(podRaw)) {
pods = append(pods, compactPod(item))
}
pipeline := decodeJSON(pipelineRaw)
return jsonMap{"ok": true, "pipelineRun": name, "namespace": serviceConfig.Namespace, "condition": conditionSummary(pipeline), "taskRuns": taskRuns, "pods": pods}, nil
}
func handleRunDevE2E(w http.ResponseWriter, r *http.Request) {
body, err := readJSON(r)
if err != nil {
handleError(w, err)
return
}
repoURL, err := requireRepoURL(body["repoUrl"])
if err != nil {
handleError(w, err)
return
}
desiredRef, err := requireDesiredRef(body["desiredRef"])
if err != nil {
handleError(w, err)
return
}
environment := stringValue(body["environment"])
if environment == "" {
environment = serviceConfig.Environment
}
if environment != "dev" {
handleError(w, &httpError{Status: http.StatusBadRequest, Msg: "only environment=dev is enabled for dev e2e"})
return
}
runID, err := optionalRunID(body["runId"])
if err != nil {
handleError(w, err)
return
}
keepNamespace := boolValue(body["keepNamespace"])
summary, err := resolveDeployManifest(repoURL, desiredRef, environment)
if err != nil {
handleError(w, err)
return
}
if runID == "" {
runID = makeRunID(summary.DeployCommit)
}
namespace := url.PathEscape(serviceConfig.Namespace)
_, raw, err := kubeRequest("POST", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns", namespace), pipelineRunBody(summary, runID, keepNamespace))
if err != nil {
handleError(w, err)
return
}
name := metadataName(decodeJSON(raw))
if name == "" {
name = "unknown-" + randomSuffix()
}
appendLog("info", "dev_e2e_started", jsonMap{"pipelineRun": name, "runId": runID, "deployCommit": summary.DeployCommit, "desiredRef": desiredRef, "environment": environment, "keepNamespace": keepNamespace})
writeJSON(w, http.StatusOK, jsonMap{
"ok": true,
"mode": "k3s-devops-managed",
"pipelineRun": name,
"namespace": serviceConfig.Namespace,
"temporaryNamespace": "unidesk-ci-e2e-" + runID,
"runId": runID,
"keepNamespace": keepNamespace,
"desiredRef": desiredRef,
"environment": environment,
"deployCommit": summary.DeployCommit,
"services": summary.Services,
"next": []string{"bun scripts/cli.ts ci logs " + name, "bun scripts/cli.ts ci status"},
})
}
func handleLogs(w http.ResponseWriter, name string) {
namespace := url.PathEscape(serviceConfig.Namespace)
selector := url.QueryEscape("tekton.dev/pipelineRun=" + name)
_, podRaw, err := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods?labelSelector=%s", namespace, selector), nil)
if err != nil {
handleError(w, err)
return
}
logs := []jsonMap{}
for index, item := range listItems(decodeJSON(podRaw)) {
if index >= 12 {
break
}
podName := metadataName(item)
if podName == "" {
continue
}
_, raw, err := kubeRequest("GET", fmt.Sprintf("/api/v1/namespaces/%s/pods/%s/log?allContainers=true&tailLines=180", namespace, url.PathEscape(podName)), nil)
if err != nil {
logs = append(logs, jsonMap{"pod": podName, "ok": false, "error": err.Error()})
} else {
logs = append(logs, jsonMap{"pod": podName, "ok": true, "text": tail(string(raw), 40000)})
}
}
writeJSON(w, http.StatusOK, jsonMap{"ok": true, "pipelineRun": name, "namespace": serviceConfig.Namespace, "logs": logs})
}
func handleCIStatus(w http.ResponseWriter) {
namespace := url.PathEscape(serviceConfig.Namespace)
_, pipelinesRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelines", namespace), nil)
_, tasksRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/tasks", namespace), nil)
_, runsRaw, _ := kubeRequest("GET", fmt.Sprintf("/apis/tekton.dev/v1/namespaces/%s/pipelineruns", namespace), nil)
writeJSON(w, http.StatusOK, jsonMap{
"ok": true,
"service": "devops",
"namespace": serviceConfig.Namespace,
"mode": "k3s-devops-managed",
"pipelines": namesFromItems(decodeJSON(pipelinesRaw)),
"tasks": namesFromItems(decodeJSON(tasksRaw)),
"recentPipelineRuns": namesFromItems(decodeJSON(runsRaw)),
})
}
func namesFromItems(value any) []string {
names := []string{}
for _, item := range listItems(value) {
if name := metadataName(item); name != "" {
names = append(names, name)
}
}
if len(names) > 20 {
return names[len(names)-20:]
}
return names
}
func router(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodOptions {
writeJSON(w, http.StatusOK, jsonMap{"ok": true})
return
}
switch {
case (r.URL.Path == "/" || r.URL.Path == "/health") && (r.Method == http.MethodGet || r.Method == http.MethodHead):
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
writeJSON(w, http.StatusOK, jsonMap{
"ok": true,
"service": "devops",
"startedAt": startedAt,
"namespace": serviceConfig.Namespace,
"mode": "k3s-devops-managed",
"normalControlPlane": "CLI -> backend-core -> k3sctl-adapter -> devops -> Kubernetes API/Tekton",
"breakGlass": "provider-gateway host.ssh remains bootstrap/recovery only",
})
case r.URL.Path == "/live" && (r.Method == http.MethodGet || r.Method == http.MethodHead):
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
writeJSON(w, http.StatusOK, jsonMap{"ok": true, "service": "devops", "startedAt": startedAt})
case r.URL.Path == "/logs" && r.Method == http.MethodGet:
writeJSON(w, http.StatusOK, jsonMap{"ok": true, "logs": recentLogs})
case r.URL.Path == "/api/ci/status" && r.Method == http.MethodGet:
handleCIStatus(w)
case r.URL.Path == "/api/ci/dev-e2e/run" && r.Method == http.MethodPost:
handleRunDevE2E(w, r)
case strings.HasPrefix(r.URL.Path, "/api/ci/runs/") && strings.HasSuffix(r.URL.Path, "/logs") && r.Method == http.MethodGet:
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/api/ci/runs/"), "/logs")
handleLogs(w, name)
case strings.HasPrefix(r.URL.Path, "/api/ci/runs/") && r.Method == http.MethodGet:
name := strings.TrimPrefix(r.URL.Path, "/api/ci/runs/")
status, err := runStatus(name)
if err != nil {
handleError(w, err)
return
}
writeJSON(w, http.StatusOK, status)
default:
writeJSON(w, http.StatusNotFound, jsonMap{"ok": false, "error": "not found"})
}
}
func main() {
appendLog("info", "service_started", jsonMap{"port": serviceConfig.Port, "namespace": serviceConfig.Namespace, "pipelineName": serviceConfig.PipelineName, "appImage": serviceConfig.AppImage})
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", serviceConfig.Host, serviceConfig.Port),
Handler: http.HandlerFunc(router),
ReadHeaderTimeout: 5 * time.Second,
}
log.Fatal(server.ListenAndServe())
}
@@ -41,7 +41,7 @@ services:
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER: "${K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER:-}"
K3SCTL_NATIVE_SERVICE_URL_DEVOPS: "${K3SCTL_NATIVE_SERVICE_URL_DEVOPS:-}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json,k3s/devops.k3s.json}"
K3SCTL_MANIFEST_PATHS: "${K3SCTL_MANIFEST_PATHS:-k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json}"
K3SCTL_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
volumes:
@@ -1,37 +0,0 @@
{
"apiVersion": "unidesk.ai/k3s/v1",
"kind": "ManagedKubernetesService",
"metadata": {
"name": "devops",
"namespace": "unidesk-ci"
},
"spec": {
"adapterServiceId": "k3sctl-adapter",
"controlPlane": {
"type": "kubernetes",
"cluster": "unidesk-k3s",
"context": "unidesk-k3s"
},
"route": {
"kind": "kubernetes-service",
"serviceName": "devops",
"servicePort": 4286
},
"activeInstanceId": "D601",
"singleWriter": true,
"expectedNodeIds": [
"D601"
],
"instances": [
{
"id": "D601",
"nodeId": "D601",
"role": "primary",
"baseUrl": "kubernetes://unidesk-ci/services/devops:4286",
"healthPath": "/health",
"healthMode": "service-proxy"
}
],
"requireAllInstancesHealthy": true
}
}
@@ -1,171 +0,0 @@
apiVersion: v1
kind: ServiceAccount
metadata:
name: devops
namespace: unidesk-ci
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: devops
namespace: unidesk-ci
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
rules:
- apiGroups: [""]
resources: ["pods"]
verbs: ["get", "list", "watch"]
- apiGroups: [""]
resources: ["pods/log"]
verbs: ["get", "list"]
- apiGroups: ["tekton.dev"]
resources: ["pipelines", "tasks", "taskruns"]
verbs: ["get", "list", "watch"]
- apiGroups: ["tekton.dev"]
resources: ["pipelineruns"]
verbs: ["get", "list", "watch", "create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: devops
namespace: unidesk-ci
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
subjects:
- kind: ServiceAccount
name: devops
namespace: unidesk-ci
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: devops
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: devops
namespace: unidesk-ci
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
unidesk.ai/deploy-service-id: devops
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: devops
unidesk.ai/instance-id: D601
template:
metadata:
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/instance-id: D601
unidesk.ai/node-id: D601
unidesk.ai/deploy-service-id: devops
annotations:
unidesk.ai/deploy-service-id: devops
unidesk.ai/deploy-repo: https://github.com/pikasTech/unidesk
unidesk.ai/deploy-commit: replace-with-deploy-env-commit
unidesk.ai/deploy-requested-commit: replace-with-deploy-env-commit
spec:
serviceAccountName: devops
nodeSelector:
unidesk.ai/node-id: D601
terminationGracePeriodSeconds: 10
containers:
- name: devops
image: unidesk-devops:dev-placeholder
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 4286
env:
- name: HOST
value: "0.0.0.0"
- name: PORT
value: "4286"
- name: DEVOPS_NAMESPACE
value: "unidesk-ci"
- name: DEVOPS_REPO_URL
value: "https://github.com/pikasTech/unidesk"
- name: DEVOPS_DESIRED_REF
value: "master"
- name: DEVOPS_ENVIRONMENT
value: "dev"
- name: UNIDESK_DEPLOY_SERVICE_ID
value: "devops"
- name: UNIDESK_DEPLOY_REPO
value: "https://github.com/pikasTech/unidesk"
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-env-commit
- name: DEVOPS_GIT_PROXY_URL
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: DEVOPS_DEV_E2E_PIPELINE
value: "unidesk-dev-namespace-e2e"
- name: DEVOPS_PIPELINE_SERVICE_ACCOUNT
value: "unidesk-ci-runner"
- name: DEVOPS_PIPELINE_WORKSPACE_CLAIM
value: "unidesk-ci-cache"
- name: DEVOPS_DEV_E2E_APP_IMAGE
value: "unidesk-code-queue:dev"
- name: LOG_FILE
value: "/var/log/unidesk/devops.jsonl"
volumeMounts:
- name: logs
mountPath: /var/log/unidesk
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 12
livenessProbe:
httpGet:
path: /live
port: http
periodSeconds: 10
timeoutSeconds: 2
failureThreshold: 6
resources:
requests:
cpu: 20m
memory: 48Mi
limits:
memory: 160Mi
volumes:
- name: logs
hostPath:
path: /home/ubuntu/.unidesk/devops-deploy/logs
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: devops
namespace: unidesk-ci
labels:
app.kubernetes.io/name: devops
app.kubernetes.io/part-of: unidesk
unidesk.ai/deployment-mode: k3sctl-managed
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: devops
unidesk.ai/instance-id: D601
ports:
- name: http
port: 4286
targetPort: http
@@ -274,7 +274,7 @@ function mergeServices(services: ManagedService[]): ManagedService[] {
}
function readConfig(): RuntimeConfig {
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json,k3s/devops.k3s.json"));
const paths = manifestPaths(envString("K3SCTL_MANIFEST_PATHS", "k3s/code-queue.k3s.json,k3s/mdtodo.k3s.json,k3s/claudeqq.k3s.json,k3s/decision-center.k3s.json"));
const inlineServices = parseServices(envString("K3SCTL_SERVICES_JSON", "[]"));
const manifestServices = readManifestServices(paths);
return {