|
|
|
@@ -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())
|
|
|
|
|
}
|