feat: add devops-controlled dev ci flow

This commit is contained in:
Codex
2026-05-18 06:59:51 +00:00
parent 348c644fde
commit b265274750
29 changed files with 2504 additions and 366 deletions
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -542,7 +542,7 @@ function TopBar({ connection, lastRefresh, onRefresh, onLogout, session, clock,
devMode ? h("div", { className: "dev-env-ribbon", "data-testid": "dev-environment-ribbon" },
h("b", null, "DEV"),
h("span", null, environment.namespace || "unidesk-dev"),
h("span", null, environment.deployRef || "origin/deploy/dev"),
h("span", null, environment.deployRef || "origin/master:deploy.json#environments.dev"),
h("span", null, shortCommit(environment.commit || environment.requestedCommit)),
) : null,
),
@@ -0,0 +1,20 @@
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"]
@@ -0,0 +1,3 @@
module github.com/pikasTech/unidesk/src/components/microservices/devops
go 1.23
+739
View File
@@ -0,0 +1,739 @@
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())
}
@@ -40,7 +40,8 @@ services:
K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE: "${K3SCTL_NATIVE_SERVICE_URL_CODE_QUEUE:-}"
K3SCTL_NATIVE_SERVICE_URL_MDTODO: "${K3SCTL_NATIVE_SERVICE_URL_MDTODO:-}"
K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER: "${K3SCTL_NATIVE_SERVICE_URL_DECISION_CENTER:-}"
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_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_SERVICES_JSON: "${K3SCTL_SERVICES_JSON:-[]}"
UNIDESK_LOG_RETENTION_BYTES: "${UNIDESK_LOG_RETENTION_BYTES:-512MiB}"
volumes:
@@ -77,6 +77,40 @@ roleRef:
name: unidesk-ci-trigger-reader
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: unidesk-ci-dev-e2e-manager
labels:
app.kubernetes.io/name: unidesk-ci
app.kubernetes.io/part-of: unidesk
rules:
- apiGroups: [""]
resources: ["namespaces"]
verbs: ["get", "list", "watch", "create", "delete", "patch"]
- apiGroups: [""]
resources: ["configmaps", "services", "pods", "pods/log"]
verbs: ["get", "list", "watch", "create", "delete", "patch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "create", "delete", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: unidesk-ci-dev-e2e-manager
labels:
app.kubernetes.io/name: unidesk-ci
app.kubernetes.io/part-of: unidesk
subjects:
- kind: ServiceAccount
name: unidesk-ci-runner
namespace: unidesk-ci
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: unidesk-ci-dev-e2e-manager
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: unidesk-ci-cross-namespace
@@ -146,7 +180,7 @@ spec:
type: string
- name: image
type: string
default: unidesk-code-queue:d601
default: unidesk-code-queue:dev
workspaces:
- name: source
volumes:
@@ -185,6 +219,7 @@ spec:
git rev-parse HEAD | tee "$(workspaces.source.path)/commit.txt"
- name: install-and-check
image: "$(params.image)"
imagePullPolicy: Never
env:
- name: DOCKER_HOST
value: unix:///var/run/docker.sock
@@ -235,12 +270,13 @@ spec:
type: string
- name: app-image
type: string
default: unidesk-code-queue:d601
default: unidesk-code-queue:dev
workspaces:
- name: source
steps:
- name: start-read-service
image: "$(params.app-image)"
imagePullPolicy: Never
env:
- name: HTTP_PROXY
value: ""
@@ -499,6 +535,7 @@ spec:
exit 1
- name: measure
image: "$(params.app-image)"
imagePullPolicy: Never
workingDir: "$(workspaces.source.path)/repo"
env:
- name: CI_CODE_QUEUE_URL
@@ -550,6 +587,7 @@ spec:
bun scripts/ci-code-queue-read-perf.ts
- name: cleanup
image: "$(params.app-image)"
imagePullPolicy: Never
env:
- name: HTTP_PROXY
value: ""
@@ -588,6 +626,408 @@ spec:
delete_resource "api/v1/namespaces/$kube_namespace/services/code-queue-ci-read"
---
apiVersion: tekton.dev/v1
kind: Task
metadata:
name: unidesk-dev-namespace-e2e
namespace: unidesk-ci
labels:
app.kubernetes.io/name: unidesk-ci
app.kubernetes.io/component: dev-namespace-e2e
spec:
params:
- name: repo-url
type: string
default: https://github.com/pikasTech/unidesk
- name: desired-ref
type: string
default: master
- name: deploy-commit
type: string
- name: environment
type: string
default: dev
- name: run-id
type: string
- name: keep-namespace
type: string
default: "false"
- name: app-image
type: string
default: unidesk-code-queue:dev
workspaces:
- name: source
steps:
- name: clone-deploy-manifest
image: alpine/git:2.45.2
env:
- name: HTTP_PROXY
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: HTTPS_PROXY
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: ALL_PROXY
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: NO_PROXY
value: "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"
- name: http_proxy
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: https_proxy
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: all_proxy
value: "http://d601-provider-egress-proxy.unidesk.svc.cluster.local:18789"
- name: no_proxy
value: "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"
script: |
#!/bin/sh
set -eu
run_dir="$(workspaces.source.path)/dev-e2e-$(params.run-id)"
rm -rf "$run_dir"
mkdir -p "$run_dir"
git clone --filter=blob:none --no-checkout "$(params.repo-url)" "$run_dir/repo"
cd "$run_dir/repo"
git fetch --depth=1 origin "$(params.desired-ref)"
actual="$(git rev-parse FETCH_HEAD)"
expected="$(params.deploy-commit)"
if [ "$actual" != "$expected" ]; then
echo "desired ref commit mismatch actual=$actual expected=$expected" >&2
exit 1
fi
git show FETCH_HEAD:deploy.json >"$run_dir/deploy.json"
printf '%s\n' "$actual" >"$run_dir/deploy-commit.txt"
- name: namespace-smoke-e2e
image: "$(params.app-image)"
imagePullPolicy: Never
env:
- name: HTTP_PROXY
value: ""
- name: HTTPS_PROXY
value: ""
- name: ALL_PROXY
value: ""
- name: NO_PROXY
value: "localhost,127.0.0.1,::1,kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local"
- name: http_proxy
value: ""
- name: https_proxy
value: ""
- name: all_proxy
value: ""
- name: no_proxy
value: "localhost,127.0.0.1,::1,kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local"
script: |
#!/usr/bin/env bash
set -euo pipefail
ns="unidesk-ci-e2e-$(params.run-id)"
keep="$(params.keep-namespace)"
run_dir="$(workspaces.source.path)/dev-e2e-$(params.run-id)"
deploy_json="$run_dir/deploy.json"
result_json="$run_dir/dev-e2e-result.json"
kube_api="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT_HTTPS}"
kube_token="$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)"
kube_ca="/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
kube() {
local method="$1"
shift
curl -fsS --cacert "$kube_ca" -H "Authorization: Bearer $kube_token" -X "$method" "$@"
}
delete_path() {
local path="$1"
local code
code="$(curl -sS -o /tmp/unidesk-dev-e2e-delete-response -w "%{http_code}" --cacert "$kube_ca" -H "Authorization: Bearer $kube_token" -X DELETE "$kube_api/$path")"
if [ "$code" = "200" ] || [ "$code" = "202" ] || [ "$code" = "404" ]; then
return 0
fi
cat /tmp/unidesk-dev-e2e-delete-response >&2
return 1
}
cleanup() {
if [ "$keep" = "true" ]; then
echo "dev_e2e_namespace_retained=$ns"
return 0
fi
delete_path "api/v1/namespaces/$ns" || true
echo "dev_e2e_namespace_deleted=$ns"
}
trap cleanup EXIT
bun - "$deploy_json" "$run_dir/dev-e2e-service-commits.env" "$(params.environment)" <<'BUN'
const [deployPath, outPath, environment] = process.argv.slice(2);
const manifest = await Bun.file(deployPath).json();
if (!manifest || manifest.schemaVersion !== 2 || !manifest.environments?.[environment]) {
throw new Error(`deploy.json must contain schemaVersion=2 environments.${environment}`);
}
const services = manifest.environments[environment].services;
if (!Array.isArray(services) || services.length === 0) {
throw new Error(`deploy.json environments.${environment}.services must contain services`);
}
const lines = [];
for (const service of services) {
if (!service?.id || !service?.commitId || !service?.repo) {
throw new Error(`each deploy.json environments.${environment} service must contain id, repo and commitId`);
}
const key = String(service.id).toUpperCase().replace(/[^A-Z0-9]/g, "_");
lines.push(`${key}_COMMIT=${service.commitId}`);
}
await Bun.write(outPath, lines.join("\n") + "\n");
console.log(JSON.stringify({ ok: true, environment, services: services.map((service) => ({ id: service.id, commitId: service.commitId })) }));
BUN
# shellcheck disable=SC1091
source "$run_dir/dev-e2e-service-commits.env"
backend_commit="${BACKEND_CORE_COMMIT:-unknown}"
frontend_commit="${FRONTEND_COMMIT:-unknown}"
code_queue_commit="${CODE_QUEUE_COMMIT:-unknown}"
delete_path "api/v1/namespaces/$ns"
cat >/tmp/dev-e2e-namespace.yaml <<YAML
apiVersion: v1
kind: Namespace
metadata:
name: $ns
labels:
app.kubernetes.io/part-of: unidesk
unidesk.ai/purpose: ci-dev-e2e
unidesk.ai/deploy-ref: master-deploy-json-dev
YAML
kube PATCH \
-H "Content-Type: application/apply-patch+yaml" \
--data-binary @/tmp/dev-e2e-namespace.yaml \
"$kube_api/api/v1/namespaces/$ns?fieldManager=unidesk-ci&force=true" >/dev/null
deploy_json_b64="$(base64 -w0 "$deploy_json")"
cat >/tmp/dev-e2e-configmap.yaml <<YAML
apiVersion: v1
kind: ConfigMap
metadata:
name: desired-manifest
namespace: $ns
labels:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/part-of: unidesk
data:
deployCommit: "$(params.deploy-commit)"
environment: "$(params.environment)"
backendCoreCommit: "$backend_commit"
frontendCommit: "$frontend_commit"
codeQueueCommit: "$code_queue_commit"
deploy.json.b64: "$deploy_json_b64"
YAML
kube PATCH \
-H "Content-Type: application/apply-patch+yaml" \
--data-binary @/tmp/dev-e2e-configmap.yaml \
"$kube_api/api/v1/namespaces/$ns/configmaps/desired-manifest?fieldManager=unidesk-ci&force=true" >/dev/null
cat >/tmp/dev-e2e-target.yaml <<YAML
apiVersion: apps/v1
kind: Deployment
metadata:
name: dev-e2e-target
namespace: $ns
labels:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/component: smoke-target
app.kubernetes.io/part-of: unidesk
spec:
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/component: smoke-target
template:
metadata:
labels:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/component: smoke-target
app.kubernetes.io/part-of: unidesk
spec:
nodeSelector:
unidesk.ai/node-id: D601
terminationGracePeriodSeconds: 5
containers:
- name: smoke-target
image: "$(params.app-image)"
imagePullPolicy: Never
command:
- bun
- -e
- |
const port = Number(process.env.PORT || 8080);
const payload = {
ok: true,
environment: "dev",
namespace: process.env.CI_E2E_NAMESPACE,
deployCommit: process.env.CI_E2E_DEPLOY_COMMIT,
backendCoreCommit: process.env.BACKEND_CORE_COMMIT,
frontendCommit: process.env.FRONTEND_COMMIT,
codeQueueCommit: process.env.CODE_QUEUE_COMMIT
};
Bun.serve({
hostname: "0.0.0.0",
port,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/health" || url.pathname === "/") {
return Response.json(payload);
}
return new Response("not found", { status: 404 });
}
});
console.log(JSON.stringify({ listening: port, ...payload }));
await new Promise(() => {});
ports:
- name: http
containerPort: 8080
env:
- name: PORT
value: "8080"
- name: CI_E2E_NAMESPACE
value: "$ns"
- name: CI_E2E_DEPLOY_COMMIT
value: "$(params.deploy-commit)"
- name: BACKEND_CORE_COMMIT
value: "$backend_commit"
- name: FRONTEND_COMMIT
value: "$frontend_commit"
- name: CODE_QUEUE_COMMIT
value: "$code_queue_commit"
readinessProbe:
httpGet:
path: /health
port: http
periodSeconds: 3
timeoutSeconds: 2
failureThreshold: 20
resources:
requests:
cpu: 20m
memory: 64Mi
limits:
memory: 256Mi
---
apiVersion: v1
kind: Service
metadata:
name: dev-e2e-target
namespace: $ns
labels:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/component: smoke-target
app.kubernetes.io/part-of: unidesk
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: unidesk-dev-namespace-e2e
app.kubernetes.io/component: smoke-target
ports:
- name: http
port: 8080
targetPort: http
YAML
csplit -s -f /tmp/dev-e2e-target- /tmp/dev-e2e-target.yaml '/^---$/' '{*}'
kube PATCH \
-H "Content-Type: application/apply-patch+yaml" \
--data-binary @/tmp/dev-e2e-target-00 \
"$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target?fieldManager=unidesk-ci&force=true" >/dev/null
kube PATCH \
-H "Content-Type: application/apply-patch+yaml" \
--data-binary @/tmp/dev-e2e-target-01 \
"$kube_api/api/v1/namespaces/$ns/services/dev-e2e-target?fieldManager=unidesk-ci&force=true" >/dev/null
deadline=$((SECONDS + 180))
while [ "$SECONDS" -lt "$deadline" ]; do
status="$(kube GET "$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target")"
available="$(printf '%s' "$status" | jq -r '.status.availableReplicas // 0')"
observed="$(printf '%s' "$status" | jq -r '.status.observedGeneration // 0')"
generation="$(printf '%s' "$status" | jq -r '.metadata.generation // 0')"
if [ "$available" -ge 1 ] && [ "$observed" -ge "$generation" ]; then
echo "dev_e2e_target_rollout=available namespace=$ns"
break
fi
sleep 2
done
if [ "$SECONDS" -ge "$deadline" ]; then
echo "dev_e2e_target_rollout=timeout namespace=$ns" >&2
kube GET "$kube_api/apis/apps/v1/namespaces/$ns/deployments/dev-e2e-target" >&2
exit 1
fi
bun - "$ns" "$(params.deploy-commit)" "$backend_commit" "$frontend_commit" "$code_queue_commit" "$result_json" <<'BUN'
const [ns, deployCommit, backendCommit, frontendCommit, codeQueueCommit, resultPath] = process.argv.slice(2);
const url = `http://dev-e2e-target.${ns}.svc.cluster.local:8080/health`;
const started = performance.now();
const response = await fetch(url);
const elapsedMs = Math.round(performance.now() - started);
const body = await response.json();
const checks = [
response.ok,
body.ok === true,
body.environment === "dev",
body.namespace === ns,
body.deployCommit === deployCommit,
body.backendCoreCommit === backendCommit,
body.frontendCommit === frontendCommit,
body.codeQueueCommit === codeQueueCommit
];
const result = { ok: checks.every(Boolean), elapsedMs, url, body };
await Bun.write(resultPath, JSON.stringify(result, null, 2) + "\n");
console.log(JSON.stringify(result));
if (!result.ok) process.exit(1);
BUN
---
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: unidesk-dev-namespace-e2e
namespace: unidesk-ci
labels:
app.kubernetes.io/name: unidesk-ci
app.kubernetes.io/component: dev-namespace-e2e
app.kubernetes.io/part-of: unidesk
spec:
params:
- name: repo-url
type: string
default: https://github.com/pikasTech/unidesk
- name: desired-ref
type: string
default: master
- name: deploy-commit
type: string
- name: environment
type: string
default: dev
- name: run-id
type: string
- name: keep-namespace
type: string
default: "false"
- name: app-image
type: string
default: unidesk-code-queue:dev
workspaces:
- name: shared-workspace
tasks:
- name: dev-namespace-e2e
taskRef:
name: unidesk-dev-namespace-e2e
params:
- name: repo-url
value: "$(params.repo-url)"
- name: desired-ref
value: "$(params.desired-ref)"
- name: deploy-commit
value: "$(params.deploy-commit)"
- name: environment
value: "$(params.environment)"
- name: run-id
value: "$(params.run-id)"
- name: keep-namespace
value: "$(params.keep-namespace)"
- name: app-image
value: "$(params.app-image)"
workspaces:
- name: source
workspace: shared-workspace
---
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
name: unidesk-ci
@@ -604,10 +1044,10 @@ spec:
type: string
- name: check-image
type: string
default: unidesk-code-queue:d601
default: unidesk-code-queue:dev
- name: code-queue-image
type: string
default: unidesk-code-queue:d601
default: unidesk-code-queue:dev
workspaces:
- name: shared-workspace
tasks:
@@ -155,8 +155,8 @@ metadata:
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/deploy-service-id: code-queue
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
spec:
replicas: 1
strategy:
@@ -205,9 +205,9 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
volumeMounts:
- name: script
mountPath: /etc/unidesk-provider-egress
@@ -250,8 +250,8 @@ metadata:
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/deploy-service-id: code-queue
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
spec:
replicas: 1
strategy:
@@ -306,13 +306,13 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_INSTANCE_ID
value: D601-dev
- name: CODE_QUEUE_SERVICE_ROLE
@@ -460,8 +460,8 @@ metadata:
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/deploy-service-id: code-queue
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
spec:
replicas: 1
selector:
@@ -514,13 +514,13 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_INSTANCE_ID
value: D601-dev-read
- name: CODE_QUEUE_SERVICE_ROLE
@@ -603,8 +603,8 @@ metadata:
unidesk.ai/deployment-mode: k3sctl-managed
unidesk.ai/deploy-service-id: code-queue
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
spec:
replicas: 1
selector:
@@ -657,13 +657,13 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: CODE_QUEUE_INSTANCE_ID
value: D601-dev-write
- name: CODE_QUEUE_SERVICE_ROLE
@@ -32,8 +32,8 @@ metadata:
app.kubernetes.io/part-of: unidesk
unidesk.ai/environment: dev
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
unidesk.ai/deploy-service-id: backend-core
spec:
replicas: 1
@@ -105,9 +105,9 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: LOG_FILE
value: /var/log/unidesk-dev/backend-core-dev.jsonl
volumeMounts:
@@ -175,8 +175,8 @@ metadata:
app.kubernetes.io/part-of: unidesk
unidesk.ai/environment: dev
annotations:
unidesk.ai/deploy-ref: origin/deploy/dev
unidesk.ai/image-source: deploy-dev-commit
unidesk.ai/deploy-ref: origin/master:deploy.json#environments.dev
unidesk.ai/image-source: deploy-env-commit
unidesk.ai/deploy-service-id: frontend
spec:
replicas: 1
@@ -250,9 +250,9 @@ spec:
- name: UNIDESK_DEPLOY_REPO
value: https://github.com/pikasTech/unidesk
- name: UNIDESK_DEPLOY_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: UNIDESK_DEPLOY_REQUESTED_COMMIT
value: replace-with-deploy-dev-commit
value: replace-with-deploy-env-commit
- name: LOG_FILE
value: /var/log/unidesk-dev/frontend-dev.jsonl
volumeMounts:
@@ -82,7 +82,7 @@ metadata:
data:
UNIDESK_ENV: dev
UNIDESK_NAMESPACE: unidesk-dev
UNIDESK_DEPLOY_REF: origin/deploy/dev
UNIDESK_DEPLOY_REF: origin/master:deploy.json#environments.dev
UNIDESK_PROVIDER_ID: D601-dev
UNIDESK_NODE_ID: D601
UNIDESK_DEV_DATABASE_NAME: unidesk_dev
@@ -177,7 +177,7 @@ data:
('namespace', 'unidesk-dev', now()),
('database', 'unidesk_dev', now()),
('provider_id', 'D601-dev', now()),
('deploy_ref', 'origin/deploy/dev', now())
('deploy_ref', 'origin/master:deploy.json#environments.dev', now())
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
CREATE TABLE IF NOT EXISTS unidesk_nodes (
@@ -0,0 +1,37 @@
{
"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
}
}
@@ -0,0 +1,171 @@
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"));
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 inlineServices = parseServices(envString("K3SCTL_SERVICES_JSON", "[]"));
const manifestServices = readManifestServices(paths);
return {