Entegrasyon dokümanı

Hook'lar

YEKE bir operasyonu uygulamadan önce sizin sisteminize sorabilir, uyguladıktan sonra haber verebilir. Bu sayfa protokolün tamamını anlatır: hangi başlıklar, hangi gövde, hangi yanıt, imza nasıl doğrulanır, endpoint cevap vermezse ne olur.

İki hook tipi ve zincirdeki yerleri

Biri kapı, diğeri haberci. Karıştırılan tek şey bu ve sonucu pahalı.

Doğrulama (validate)

Operasyon uygulanmadan önce sorulur ve durdurabilir. Yanıtınız planın akıbetini belirler.

  • Anı: plan kurulurken, onaya basıldığı an, ya da ikisi.
  • Yanıtınız: allow / deny / elevate.
  • Ulaşılamazsa: onFailure belirler — varsayılan deny.

Bildirim (notify)

Operasyon uygulandıktan sonra haber verilir ve hiçbir şeyi durdurmaz. Kullanıcı sonucu çoktan almıştır.

  • Anı: apply bittikten sonra, kalıcı bir kuyruktan.
  • Yanıtınız: herhangi bir 2xx. Gövdeye bakılmaz.
  • Ulaşılamazsa: tekrar denenir, sonra ölü kuyruğa düşer.

Bir yazma isteğinin izlediği yol — hook'lar kalın:

  istek  →  guardrail  →  dry-run  →  HOOK (stage: plan)
                                          │
                                          ├─ deny     → plan DENIED, onay kartı hiç doğmaz
                                          ├─ elevate  → onay kartı "kaynağın adını yaz" ister
                                          └─ allow    → onay kartı açılır
                                                          │
                                             kullanıcı onaya basar
                                                          │
                                                       HOOK (stage: approve)
                                                          │
                                          ┌───────────────┴─ deny → 409, plan BEKLEMEDE kalır
                                          │
                                        apply
                                          │
                                       HOOK (stage: notify)  ← kuyruktan, asenkron

Guardrail ile hook aynı şey değildir. Cevabı planın şeklinden tek başına bilinebilen kural guardrail'e aittir (“bu namespace'te silme yasak”) ve ağ turu bile atmaz. Hook, cevabını yalnızca dışarının bilebileceği sorular içindir: açık change ticket var mı, bakım penceresi mi, nöbetçi kim.

İstek: başlıklar ve gövde

Her zaman POST, her zaman JSON. Yönlendirme izlenmez — 3xx dönerseniz çağrı hata sayılır.

BaşlıkHer zamanDeğer
content-typeEvetapplication/json
user-agentEvetyeke-core — sabit ve sürümsüz. Erişim günlüğünüzde isteğin YEKE'den geldiğini görebilesiniz diye.
x-yeke-hookEvetHook'un kimliği — sizin verdiğiniz ad.
x-yeke-deliveryEvetTeslim UUID'si. Idempotency anahtarınız budur; tekrar denemelerde aynı kalır.
authorizationHayırYalnız tanımladıysanız. Değer ham geçer — Bearer …, Basic …, ne yazdıysanız o.
x-yeke-signature-256HayırYalnız imza sırrı tanımladıysanız. Gövdenin HMAC-SHA256'sı, hex.

Doğrulama gövdesi

stage alanı plan ya da approve taşır; gövdenin geri kalanı ikisinde de aynıdır.

{
  "schemaVersion": 1,
  "hookId": "change-board",
  "deliveryId": "6f1c9d84-2b77-4a51-9e0e-7a2f1c0d8b33",
  "stage": "plan",
  "plan": {
    "id": "op-01KZGTWSG5WSSR8PDZ35",
    "clusterId": "c-3",
    "source": "ui",
    "createdAt": "2026-08-08T14:02:09.144Z",
    "actor":    { "userId": "u-2", "username": "deniz" },
    "identity": { "user": "deniz", "groups": ["platform"] },
    "origins": [
      { "method": "PATCH", "path": "/apis/apps/v1/namespaces/uretim/deployments/api" }
    ],
    "steps": [
      {
        "target": {
          "schemaId": "apps/v1/Deployment", "group": "apps", "version": "v1",
          "kind": "Deployment", "resource": "deployments",
          "namespaced": true, "namespace": "uretim", "name": "api",
          "subresource": null, "uid": "9c1…", "resourceVersion": "122835"
        },
        "classification": { "severity": "mutating", "flags": ["owner-managed"] },
        "changedPaths": ["/spec/replicas"],
        "dryRun": { "status": "ok" }
      }
    ],
    "policy": {
      "verdict": "allow",
      "approvalLevel": "standard",
      "matches": [
        { "policyId": "uretim.replica-tavani", "effect": "allow", "message": null }
      ]
    }
  }
}
AlanDeğerlerNe demek
stageplan · approve · testtest, yönetim ekranındaki Sına düğmesidir ve gövdesinde plan yoktur; kararı hiçbir şeye dönüşmez.
sourceui · nl · apiİsteği doğuran yüzey. nl = sohbet.
actornesne · nullYEKE kullanıcısı. Otomasyondan gelen bir istekte null olabilir.
identitynesneKubernetes kimliği — apiserver'a gerçekten bu kimlikle gidilir.
origins[].methodPOST · PATCH · PUT · DELETEHam HTTP niyeti. Fiil buradan okunur.
classification.severitymutating · destructiveYıkıcılık sınıfı; nesnenin şeklinden türetilir.
classification.flagsdizicluster-scoped, collection, finalizers, owner-managed, orphan-delete, dry-run-unsupported, irreversible, stream
changedPathsJSON Pointer dizisiHangi yolların değişeceği. Değerler yok, yalnız yollar.
dryRun.statusok · failed · unsupported · skipped · not-applicableapiserver'ın dry-run'ı ne dedi.
policy.verdictallow · deny · elevate · errorYEKE'nin kendi guardrail kararı — sizinkinden önce.
policy.approvalLevelstandard · elevatedOnay kartının bugünkü çıtası.

Gövdede nesne DEĞERİ yoktur ve olmayacaktır. Manifest yok, önceki hâl yok, dry-run sonucu yok, istek gövdesi yok. Hook “ne olacağını” bilmek zorunda, “hangi değerin ne olacağını” değil. Bir Secret güncellenirken bile changedPaths ["/data/password"] der, parolayı değil. Bunu bir ayarla açamazsınız: gövdeyi üreten tek fonksiyon zaten başka bir şey üretmiyor.

Bildirim gövdesi

stage her zaman notify. Adım şekli doğrulamayla aynı; farkı event ve result.

{
  "schemaVersion": 1,
  "hookId": "slack-kanali",
  "deliveryId": "b3d0…",
  "stage": "notify",
  "event": { "type": "plan.applied", "ts": "2026-08-08T14:02:14.881Z" },
  "operation": {
    "id": "op-01KZGTWSG5WSSR8PDZ35",
    "clusterId": "c-3",
    "source": "ui",
    "revertOf": null,
    "actor":    { "userId": "u-2", "username": "deniz" },
    "identity": { "user": "deniz", "groups": ["platform"] },
    "origins": [ { "method": "PATCH", "path": "/apis/apps/v1/…" } ],
    "steps":   [ { "target": { … }, "classification": { … }, "changedPaths": ["/spec/replicas"] } ],
    "result": {
      "state": "APPLIED",
      "stepsApplied": 1,
      "appliedAt": "2026-08-08T14:02:14.702Z",
      "reason": null
    }
  }
}

event.type yalnız üç değer alabilir ve bu sınır şemada donmuştur:

  • plan.applied — bütün adımlar uygulandı.
  • plan.partially_applied — bir kısmı uygulandı; stepsApplied kaçını söyler.
  • plan.failed — uygulanamadı; result.reason apiserver'ın kendi cümlesini taşır.

revertOf doluysa bu operasyon başka bir operasyonun geri alınmasıdır ve değeri o operasyonun kimliğidir — korelasyonu kurabileceğiniz tek yer burasıdır.

Yanıt sözleşmesi

Doğrulamada gövde okunur, bildirimde okunmaz. Sözlük üç kelimeden ibarettir.

HTTP/1.1 200 OK
content-type: application/json

{ "verdict": "deny", "reason": "CHG-0042 kapalı — açık change kaydı yok" }
verdictstage: planstage: approve
allowPlan ilerler, onay kartı açılır.Kapı açılır, apply koşar.
denyPlan DENIED. Onay kartı hiç doğmaz. Terminal.409. Plan AWAITING_APPROVAL'da bekler, kayıt değişmez. Geçicidir — pencere açılınca aynı karta tekrar basılır.
elevateOnay çıtası yükselir: kart kaynağın adını yazmanızı ister.Ret + tazeleme yönlendirmesi. Donmuş bir planın çıtası değiştirilemez; boru hattı baştan koşar ve elevate doğru yerde söylenir.
  • reason zorunlu değil ama işe yarar. Kullanıcının ekranda gördüğü cümle odur — YEKE onu çevirmez, kendi cümlesiyle değiştirmez. 2048 karakterde kırpılır.
  • Tanımadığımız alanlar sorun değil. Yanıtınıza ticketId koyabilirsiniz; yok sayılır. Yanıt sizin şemanız — alan eklediğiniz gün entegrasyonunuzun sessizce her planı engellemesi kabul edilebilir bir davranış olmazdı.
  • Gövde tavanı 64 KiB. Aşarsa yanıt anlaşılmamış sayılır.
  • Bildirimde gövdeye hiç bakılmaz. 204 yeter; 2xx = teslim edildi.

2xx ama karar yok ile ulaşılamadı ayrı arızalardır ve ayrı görünürler. Birincisi (gövde JSON değil, verdict yok, tanınmayan değer) sizi yanıtın biçimine yollar ve tekrar denenmez — deterministik bir uyumsuzluktur. İkincisi (bağlantı, zaman aşımı, 5xx) servisin kendisine yollar ve bir kez tekrar denenir.

İmza doğrulama

Bir imza sırrı tanımlarsanız her istek x-yeke-signature-256 taşır.

İmza, ham gövdenin HMAC-SHA256'sıdır, hex kodlu. Başlıklar imzaya dâhil değildir: başlıkları da kapsayan kanonik bir dize, sizden bizim sıralama kuralımızı yeniden yazmanızı isterdi ve o gerçeklemenin ayrıştığı gün imza sessizce tutmazdı. Tekrar oynatmaya karşı koruma gövdenin içinde: deliveryId imzanın altındadır.

Gövdeyi ayrıştırmadan önce doğrulayın ve ham baytlar üzerinde çalışın — JSON'u ayrıştırıp yeniden serileştirmek anahtar sırasını ve boşlukları değiştirir, imza tutmaz.

// Node.js — express, ham gövde ile
import { createHmac, timingSafeEqual } from "node:crypto";

app.post("/yeke/hook", express.raw({ type: "application/json" }), (req, res) => {
  const expected = createHmac("sha256", process.env.YEKE_HOOK_SECRET)
    .update(req.body)                      // Buffer — ayrıştırılmamış hâli
    .digest("hex");
  const got = req.get("x-yeke-signature-256") ?? "";

  // Uzunluk eşit değilse timingSafeEqual FIRLATIR; önce onu ele.
  if (got.length !== expected.length ||
      !timingSafeEqual(Buffer.from(got), Buffer.from(expected))) {
    return res.status(401).json({ error: "bad signature" });
  }

  const body = JSON.parse(req.body.toString("utf8"));
  res.json({ verdict: "allow" });
});
# Python — Flask
import hmac, hashlib, os
from flask import request, jsonify

@app.post("/yeke/hook")
def yeke_hook():
    raw = request.get_data()               # bytes, ayrıştırılmamış
    expected = hmac.new(os.environ["YEKE_HOOK_SECRET"].encode(),
                        raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, request.headers.get("x-yeke-signature-256", "")):
        return jsonify(error="bad signature"), 401

    body = request.get_json()
    return jsonify(verdict="allow")

İmza sırrı en az 8 karakter olmalıdır. Daha kısası reddedilir; sebep teşhis metinlerindeki maskedir — kısa bir dize her yerde eşleşir ve maskeyi işe yaramaz hâle getirirdi. Sır bir kez yazılır, bir daha okunamaz: ekran bir sırrın var olduğunu gösterebilir, ne olduğunu asla.

Zaman aşımı, tekrar, fail-closed

İki tipin dayanıklılık modeli farklıdır, çünkü birinde bir insan bekliyor.

DoğrulamaBildirim
Zaman aşımıtimeoutMs500–10000 ms, varsayılan 5000. Her deneme için taze kurulur.
TekrarEn fazla 2 deneme (yani bir tekrar), yalnız geçici arızada. Sabit; ayarlanamaz.maxAttempts1–10. 1 = hiç tekrarlama.
BeklemeKısa ve jitter'lı: onay bekleyen bir insanın penceresindeyiz.Üstel: 60 sn tabanlı, ×2, %20 jitter, tavan 15 dk.
Neyin tekrarıAğ hatası, zaman aşımı ve 5xx. 4xx ve anlaşılmayan gövde tekrarlanmaz — deterministik yanlış yapılandırmadır; tekrarlamak yalnız gecikmeyi ve kuyruğu şişirir.
TükeninceonFailure: deny → plan durur · warn → plan ilerler, iz kalır.Ölü kuyruğa düşer ve denetim izine hook.delivery_failed yazılır.

Varsayılan fail-closed'dur ve bedeli açıktır: onFailure: deny kurulu bir doğrulama hook'unun endpoint'i çökerse o cluster'da yazma durur. Dengeleyicileri şunlar — 10 sn'lik zaman aşımı tavanı, bilinçli bir taviz olarak warn modu, hook'u ekrandan kapatan acil yol, ve hook hiç kurulmadığında riskin sıfır olması. Zaman aşımı ve ağ arızası da operasyonu durdurur — yalnız sizin deny'ınız değil.

Idempotency

x-yeke-delivery tekrar denemelerde aynı kalır. Yan etkili bir endpoint yazıyorsanız (ticket açmak, kanala mesaj düşmek) o değeri saklayın ve aynı teslim ikinci kez geldiğinde işlemi tekrarlamayın. Doğrulama tarafında ek bir tuzak var: aynı plan stage: plan ve stage: approve ile iki kez sorulur ve bunlar ayrı teslimlerdir — aynı plana ait olduklarını plan.id söyler.

Hook'u tanımlama

Yönetim ekranından: Yönetim → Hook'lar → Yeni hook. Yeniden başlatma gerekmez.

AlanTipNot
Kimlikikisi deDenetim izinde, teslim kuyruğunda ve x-yeke-hook başlığında bu adla anılır. Sonradan değiştirilemez.
Adresikisi dehttp ya da https. Host'un izin listesinde olması şart (aşağıda).
Zaman aşımıikisi de500–10000 ms.
Kapsamikisi deTüm cluster'lar ya da seçili cluster'lar. Eşleşme cluster kimliğiyledir, adıyla değil — ad değişebilir, kimlik değişmez.
Aşamalardoğrulamaplan, approve ya da ikisi. Yazılmazsa plan. Yalnız bakım penceresi bekleten bir hook sadece approve seçebilir.
Ulaşılamazsadoğrulamadeny (varsayılan, fail-closed) ya da warn.
OlaylarbildirimÜç terminal sonuçtan seçim. Başka bir denetim olayına abone olunamaz.
Teslim denemesibildirim1–10.
Sırlarikisi deauthorization başlığı ve/veya imza sırrı. Tek yönlü saklanır.

Adresin host'u YEKE_HOOK_ALLOWED_HOSTS içinde olmalıdır. Bu liste boşken core, iç ağa (loopback, RFC1918, link-local, ULA) çözülen hiçbir hook adresine çıkmaz — genel internetteki bir endpoint çalışmaya devam eder, kesilen tek şey hook'ların hiç işi olmayan iç ağdır. Sebep SSRF: hook adresini core çağırır ve core cluster'larınıza erişebilen bir ağ konumunda oturur; kutudan çıktığı hâliyle ürün, yönetici ekranından sürülebilen bir iç-ağ tarayıcısı olmamalıdır. İç bir adrese bilerek hook kurmak meşru bir hamledir ve yolu açıktır — listeye yazmak:

YEKE_HOOK_ALLOWED_HOSTS=itsm.ic.ornek.com,change-board

Liste verildiğinde liste kazanır ve iç adres kontrolü hiç koşmaz. Kapı iki yerde birden işler — kaydı yazarken ve her çağrıda; ikincisi olmasa listeyi daraltmanın hiçbir etkisi olmaz, daha önce kaydedilmiş hook'lar sessizce çıkmaya devam ederdi. Eşleşme host dizesi üzerindedir ve port dâhil değildir: ornek.com listedeyse o host'un her portu açıktır.

Eksikse arıza yanıltıcıdır. Ağ erişilebilir olsa bile her çağrı HOOK_HOST_NOT_ALLOWED ile düşer ve fail-closed kurulumda bu, “hook çalışıyor ama hep hayır diyor” gibi görünür. Teşhisin ucuz yolu core log'unda o kodu aramaktır.

Çalışan referans endpoint

Bağımlılıksız, tek dosya. Kopyalayın, çalıştırın, adresini hook'a yazın.

// node yeke-hook.mjs — :8787/hook (doğrulama) ve :8787/notify (bildirim)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";

const SECRET = process.env.YEKE_HOOK_SECRET;
const seen = new Set();                    // idempotency: deliveryId

const read = async (req) => {
  const chunks = [];
  for await (const c of req) chunks.push(c);
  return Buffer.concat(chunks);
};

const signatureOk = (raw, header) => {
  if (!SECRET) return true;                // sır yoksa imza da yok
  const expected = createHmac("sha256", SECRET).update(raw).digest("hex");
  const got = header ?? "";
  return got.length === expected.length &&
         timingSafeEqual(Buffer.from(got), Buffer.from(expected));
};

createServer(async (req, res) => {
  const raw = await read(req);
  const json = (code, payload) => {
    res.writeHead(code, { "content-type": "application/json" });
    res.end(JSON.stringify(payload));
  };

  if (!signatureOk(raw, req.headers["x-yeke-signature-256"])) {
    return json(401, { error: "bad signature" });
  }

  const body = JSON.parse(raw.toString("utf8") || "{}");

  // ── doğrulama ────────────────────────────────────────────────
  if (req.url === "/hook") {
    // Sınama çağrısında plan YOK; ulaşılabilirlik ölçülüyor.
    if (body.stage === "test") return json(200, { verdict: "allow" });

    // Karar YALNIZCA dışarının bildiği bir şeye dayanmalı.
    const ns = body.plan?.steps?.[0]?.target?.namespace;
    const ticket = await acikChangeKaydi(ns);          // ← sizin ITSM'iniz

    return ticket
      ? json(200, { verdict: "allow" })
      : json(200, { verdict: "deny",
                    reason: `${ns} için açık change kaydı yok` });
  }

  // ── bildirim ─────────────────────────────────────────────────
  if (req.url === "/notify") {
    // Aynı teslim iki kez gelebilir; yan etki bir kez koşmalı.
    if (!seen.has(body.deliveryId)) {
      seen.add(body.deliveryId);
      await kanalaYaz(body.event.type, body.operation);   // ← sizin Slack'iniz
    }
    res.writeHead(204).end();                // 2xx = teslim alındı
    return;
  }

  json(404, { error: "not found" });
}).listen(8787);

Kurduktan sonra yönetim ekranındaki Sına düğmesi endpoint'in ayakta olduğunu ve kimliğinizi kabul ettiğini tek çağrıda ölçer. O çağrının kararı hiçbir şeye dönüşmez — deny dönseniz bile hiçbir plan bloklanmaz.

Hook'un yapamadıkları

Bunların hepsi kodda zorlanıyor; entegrasyonunuzu bunlara göre tasarlayın.

  • allow insan onayını atlamaz. Siz “evet” derken bile onay kartı açılır ve bir insanın basması beklenir. Hook kapı ekler, kapı kaldırmaz.
  • Hook planı değiştiremez. Yanıt sözlüğü üç kelimedir. Replika sayısını düzeltmek, bir adım eklemek, hedefi değiştirmek — hiçbiri mümkün değil, alanı da yok.
  • Denetim izinin tamamına abone olunamaz. Yalnız üç terminal operasyon sonucu teslim edilir. auth.login gibi bir olay yazmayı denerseniz kayıt reddedilir ve ret, sınırı adıyla söyler. İzin tamamının dışarı aktarımı ayrı bir yetenektir ve garantileri (sürekli akış, boşluk tespiti, imzalı biçim) bundan farklıdır.
  • Nesne değeri hiç görmezsiniz. Yukarıdaki gövde disiplini bir ayar değil sınırdır.
  • Yönlendirme izlenmez. 3xx bir hatadır: bir 302, imzalı gövdeyi ve authorization başlığını operatörün yazmadığı bir host'a taşırdı.
  • Yanıtınız 2xx olmalı. 200 + karar taşıyan gövde, ya da bildirimde herhangi bir 2xx.

Kurmadan önce denemek ister misiniz?

Kendi sisteminizi bağlamadan önce zincirin nasıl davrandığını görmek için bir simülatör var: dış sistemi oynayan sahte bir endpoint ve gelen her isteğin ham gövdesini gösteren bir panel. Yukarıdaki “gövdede nesne değeri yoktur” iddiasını gözle doğrulayabileceğiniz yer orası.