Chapters ▾ 2nd Edition

8.4 Anpassa Git - Ett exempel på Git‑upprätthållen policy

Ett exempel på Git‑upprätthållen policy

I det här avsnittet använder du det du lärt dig för att skapa ett Git‑arbetsflöde som kontrollerar ett anpassat format på incheckningsmeddelanden och som bara tillåter vissa användare att ändra vissa underkataloger i ett projekt. Du bygger klientskript som hjälper utvecklaren att veta om deras uppskickning kommer att nekas, och serverskript som faktiskt upprätthåller policyn.

Skripten vi visar är skrivna i Ruby; delvis på grund av vår egen tröghet, men också för att Ruby är lätt att läsa även om du inte nödvändigtvis kan skriva det. Vilket språk som helst fungerar dock – alla exempel‑krokskript som följer med Git är antingen Perl eller Bash, så du kan se gott om krokskript i de språken genom att titta på exemplen.

Krok på serversidan

Allt arbete på serversidan hamnar i filen update i din hooks‑katalog. Kroken update körs en gång per gren som skickas upp och tar tre argument:

  • Namnet på referensen som skickas upp till

  • Den gamla revisionen där grenen låg

  • Den nya revisionen som skickas upp

Du har också tillgång till användaren som skickar upp om uppskickningen körs över SSH. Om du har tillåtit alla att ansluta med en enda användare (som “git”) via publika nycklar kan du behöva ge den användaren ett skalomslag som avgör vilken användare som ansluter baserat på den publika nyckeln, och sätta en miljövariabel därefter. Här antar vi att anslutande användare ligger i miljövariabeln $USER, så ditt update‑skript börjar med att samla in all information du behöver:

#!/usr/bin/env ruby

$refname = ARGV[0]
$oldrev  = ARGV[1]
$newrev  = ARGV[2]
$user    = ENV['USER']

puts "Enforcing Policies..."
puts "(#{$refname}) (#{$oldrev[0,6]}) (#{$newrev[0,6]})"

Ja, det här är globala variabler. Döm oss inte – det är enklare att visa på det här sättet.

Upprätthålla ett specifikt incheckningsmeddelandeformat

Din första utmaning är att säkerställa att varje incheckningsmeddelande följer ett visst format. Som mål antar vi att varje meddelande måste innehålla en sträng som ser ut som “ref: 1234” eftersom du vill att varje incheckning ska länka till ett arbetsobjekt i ditt ärendehanteringssystem. Du måste titta på varje incheckning som skickas upp, se om strängen finns i incheckningsmeddelandet och, om strängen saknas i någon av incheckningarna, avsluta med en icke‑nollkod så att uppskickningen nekas.

Du kan få en lista över SHA‑1‑värdena för alla incheckningar som skickas upp genom att ta $newrev och $oldrev och skicka dem till ett Git‑lågnivåkommando som heter git rev-list. Det motsvarar i praktiken git log, men skriver som standard bara ut SHA-1-värden utan annan information. Så, för att få en lista över alla inchecknings‑SHA‑1:or mellan en incheckning och en annan kan du köra något i stil med detta:

$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475

Du kan ta den utdata, iterera över varje SHA‑1, hämta meddelandet och testa det mot ett reguljärt uttryck som söker efter mönstret.

Du behöver lista ut hur du hämtar incheckningsmeddelandet från varje incheckning för att testa. För att få rå incheckningsdata kan du använda ett annat lågnivåkommando som heter git cat-file. Vi går igenom alla dessa lågnivåkommandon i detalj i Git bakom kulisserna, men tills vidare är det här vad kommandot ger dig:

$ git cat-file commit ca82a6
tree cfda3bf379e4f8dba8717dee55aab78aef7f4daf
parent 085bb3bcb608e1e8451d4b2432f8ecbe6306e7e7
author Scott Chacon <schacon@gmail.com> 1205815931 -0700
committer Scott Chacon <schacon@gmail.com> 1240030591 -0700

Change the version number

Ett enkelt sätt att få incheckningsmeddelandet när du har SHA‑1‑värdet är att gå till första tomraden och ta allt därefter. Du kan göra det med kommandot sed på Unix‑system:

$ git cat-file commit ca82a6 | sed '1,/^$/d'
Change the version number

Du kan använda den kommandosekvensen för att hämta incheckningsmeddelandet från varje incheckning som försöker skickas upp och avsluta om du ser något som inte matchar. För att avsluta skriptet och neka uppskickningen avslutar du med en icke‑nollkod. Hela metoden ser ut så här:

$regex = /\[ref: (\d+)\]/

# enforced custom commit message format
def check_message_format
  missed_revs = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  missed_revs.each do |rev|
    message = `git cat-file commit #{rev} | sed '1,/^$/d'`
    if !$regex.match(message)
      puts "[POLICY] Your message is not formatted correctly"
      exit 1
    end
  end
end
check_message_format

Om du lägger detta i ditt update‑skript kommer uppdateringar som innehåller incheckningar vars meddelanden inte följer regeln att nekas.

Upprätthålla ett användarbaserat ACL‑system

Anta att du vill lägga till en mekanism som använder en åtkomstkontrollista (ACL) som anger vilka användare som får skicka ändringar till vilka delar av projekten. Vissa personer har full åtkomst, medan andra bara får skicka ändringar till vissa underkataloger eller specifika filer. För att upprätthålla detta skriver du dessa regler till en fil med namnet acl som ligger i ditt nakna Git‑kodförråd på servern. Du låter update‑kroken läsa dessa regler, se vilka filer som introduceras i alla incheckningar som skickas upp och avgöra om användaren som skickar upp har åtkomst till att uppdatera alla dessa filer.

Det första du gör är att skriva din ACL. Här använder du ett format som ligger nära CVS‑ACL‑mekanismen: den använder en serie rader, där första fältet är avail eller unavail, nästa fält är en kommaseparerad lista över användare som regeln gäller för, och sista fältet är sökvägen som regeln gäller (tomt betyder öppen åtkomst). Alla dessa fält avgränsas av ett pipe‑tecken (|).

I det här fallet har du ett par administratörer, några dokumentationsskribenter med åtkomst till katalogen doc, och en utvecklare som bara har åtkomst till katalogerna lib och tests, och din ACL‑fil ser ut så här:

avail|nickh,pjhyett,defunkt,tpw
avail|usinclair,cdickens,ebronte|doc
avail|schacon|lib
avail|schacon|tests

Du börjar med att läsa in dessa data i en struktur som du kan använda. För att hålla exemplet enkelt upprätthåller du bara avail‑direktiven. Här är en metod som ger dig en associativ array där nyckeln är användarnamnet och värdet är en lista över sökvägar som användaren har skrivrättigheter till:

def get_acl_access_data(acl_file)
  # read in ACL data
  acl_file = File.read(acl_file).split("\n").reject { |line| line == '' }
  access = {}
  acl_file.each do |line|
    avail, users, path = line.split('|')
    next unless avail == 'avail'
    users.split(',').each do |user|
      access[user] ||= []
      access[user] << path
    end
  end
  access
end

På ACL‑filen vi såg tidigare returnerar get_acl_access_data en datastruktur som ser ut så här:

{"defunkt"=>[nil],
 "tpw"=>[nil],
 "nickh"=>[nil],
 "pjhyett"=>[nil],
 "schacon"=>["lib", "tests"],
 "cdickens"=>["doc"],
 "usinclair"=>["doc"],
 "ebronte"=>["doc"]}

Nu när du har sorterat behörigheterna behöver du avgöra vilka sökvägar som incheckningarna som skickas upp har modifierat, så att du kan säkerställa att användaren som skickar upp har åtkomst till alla dessa.

Du kan ganska enkelt se vilka filer som har ändrats i en enskild incheckning med alternativet --name-only till kommandot git log (kort nämnt i Grunderna i Git):

$ git log -1 --name-only --pretty=format:'' 9f585d

README
lib/test.rb

Om du använder ACL‑strukturen som returneras från get_acl_access_data och jämför den mot listan över filer i varje incheckning kan du avgöra om användaren har åtkomst att skicka alla sina incheckningar:

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('acl')

  # see if anyone is trying to push something they can't
  new_commits = `git rev-list #{$oldrev}..#{$newrev}`.split("\n")
  new_commits.each do |rev|
    files_modified = `git log -1 --name-only --pretty=format:'' #{rev}`.split("\n")
    files_modified.each do |path|
      next if path.size == 0
      has_file_access = false
      access[$user].each do |access_path|
        if !access_path  # user has access to everything
           || (path.start_with? access_path) # access to this path
          has_file_access = true
        end
      end
      if !has_file_access
        puts "[POLICY] You do not have access to push to #{path}"
        exit 1
      end
    end
  end
end

check_directory_perms

Du får en lista över nya incheckningar som skickas upp till servern med git rev-list. Sedan, för varje incheckning, tar du reda på vilka filer som ändrats och ser till att användaren som skickar upp har åtkomst till alla sökvägar som ändras.

Nu kan dina användare inte skicka några incheckningar med felaktigt formaterade meddelanden eller med ändrade filer utanför sina tilldelade sökvägar.

Testa det

Om du kör chmod u+x .git/hooks/update, vilket är filen där du borde ha lagt all denna kod, och sedan försöker skicka en incheckning med ett icke‑kompatibelt meddelande, får du något i stil med detta:

$ git push -f origin master
Counting objects: 5, done.
Compressing objects: 100% (3/3), done.
Writing objects: 100% (3/3), 323 bytes, done.
Total 3 (delta 1), reused 0 (delta 0)
Unpacking objects: 100% (3/3), done.
Enforcing Policies...
(refs/heads/master) (8338c5) (c5b616)
[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master
To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

Det finns ett par intressanta saker här. Först ser du det här när kroken börjar köra.

Enforcing Policies...
(refs/heads/master) (fb8c72) (c56860)

Kom ihåg att du skrev ut detta allra först i update‑skriptet. Allt som ditt skript skriver till stdout kommer att skickas till klienten.

Nästa sak du märker är felmeddelandet.

[POLICY] Your message is not formatted correctly
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master

Den första raden skrevs ut av dig, de två andra kommer från Git och säger att update‑skriptet avslutades med icke‑nollkod och det är det som nekar uppskickningen. Till sist har du detta:

To git@gitserver:project.git
 ! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'

Du ser ett "fjärrkodförråd avvisad"‑meddelande för varje referens som din krok nekade, och den säger att den nekades specifikt på grund av ett krokfel.

Dessutom, om någon försöker redigera en fil de inte har åtkomst till och skickar upp en incheckning som innehåller den, ser de något liknande. Till exempel, om en dokumentationsförfattare försöker skicka en incheckning som ändrar något i katalogen lib ser de:

[POLICY] You do not have access to push to lib/test.rb

Från och med nu, så länge update‑skriptet finns där och är körbart, kommer ditt kodförråd aldrig ha ett incheckningsmeddelande utan ditt mönster, och dina användare är sandlådade.

Krokar på klientsidan

Nackdelen med detta angreppssätt är det gnäll som ofrånkomligen uppstår när användarnas uppskick av incheckningar nekas. Att få sitt omsorgsfullt skapade arbete nekat i sista stund kan vara extremt frustrerande och förvirrande; dessutom måste de redigera sin historik för att rätta det, vilket inte alltid är för den lättskrämde.

Svaret på detta dilemma är att tillhandahålla några krokar på klientsidan som användarna kan köra för att få en varning när de gör något som servern sannolikt kommer att neka. På så vis kan de rätta problemen innan de checkar in och innan problemen blir svårare att fixa. Eftersom krokar inte överförs med en klon av ett projekt måste du distribuera dessa skript på annat sätt och sedan få användarna att kopiera dem till sin .git/hooks‑katalog och göra dem körbara. Du kan distribuera dessa krokar i projektet eller i ett separat projekt, men Git sätter dem inte upp automatiskt.

Till att börja med bör du kontrollera incheckningsmeddelandet precis innan varje incheckning registreras, så att du vet att servern inte kommer att neka dina ändringar på grund av dåligt formaterade incheckningsmeddelanden. För att göra detta kan du lägga till kroken commit-msg. Om du låter den läsa meddelandet från filen som ges som första argument och jämför det mot mönstret kan du få Git att avbryta incheckningen om det inte finns någon matchning:

#!/usr/bin/env ruby
message_file = ARGV[0]
message = File.read(message_file)

$regex = /\[ref: (\d+)\]/

if !$regex.match(message)
  puts "[POLICY] Your message is not formatted correctly"
  exit 1
end

Om skriptet ligger på plats (i .git/hooks/commit-msg) och är körbart, och du checkar in med ett meddelande som inte är korrekt formaterat, ser du detta:

$ git commit -am 'Test'
[POLICY] Your message is not formatted correctly

Ingen incheckning slutfördes i det fallet. Om ditt meddelande däremot innehåller rätt mönster tillåter Git att du checkar in:

$ git commit -am 'Test [ref: 132]'
[master e05c914] Test [ref: 132]
 1 file changed, 1 insertions(+), 0 deletions(-)

Nästa steg är att se till att du inte ändrar filer som ligger utanför dina ACL‑behörigheter. Om projektets .git‑katalog innehåller en kopia av ACL‑filen du använde tidigare, kommer följande pre-commit‑skript att upprätthålla de begränsningarna åt dig:

#!/usr/bin/env ruby

$user    = ENV['USER']

# [ insert acl_access_data method from above ]

# only allows certain users to modify certain subdirectories in a project
def check_directory_perms
  access = get_acl_access_data('.git/acl')

  files_modified = `git diff-index --cached --name-only HEAD`.split("\n")
  files_modified.each do |path|
    next if path.size == 0
    has_file_access = false
    access[$user].each do |access_path|
    if !access_path || (path.index(access_path) == 0)
      has_file_access = true
    end
    if !has_file_access
      puts "[POLICY] You do not have access to push to #{path}"
      exit 1
    end
  end
end

check_directory_perms

Det här är ungefär samma skript som på serversidan, men med två viktiga skillnader. För det första ligger ACL‑filen på en annan plats, eftersom detta skript körs från din arbetskatalog och inte från din .git‑katalog. Du måste ändra sökvägen till ACL‑filen från detta:

access = get_acl_access_data('acl')

till detta:

access = get_acl_access_data('.git/acl')

Den andra viktiga skillnaden är hur du får en lista över filer som ändrats. Eftersom metoden på serversidan tittar på loggen av incheckningar, och i detta läge är incheckningen ännu inte registrerad, måste du ta listan från köytan i stället. I stället för:

files_modified = `git log -1 --name-only --pretty=format:'' #{ref}`

måste du använda:

files_modified = `git diff-index --cached --name-only HEAD`

Men det är de enda två skillnaderna – i övrigt fungerar skriptet på samma sätt. En varning är att det förutsätter att du kör lokalt som samma användare som du skickar upp som till fjärrmaskinen. Om det skiljer sig måste du sätta variabeln $user manuellt.

En annan sak vi kan göra här är att se till att användaren inte skickar upp icke‑snabbspolade referenser. För att få en referens som inte är en snabbspolning måste du antingen ombasera förbi en incheckning du redan har skickat upp eller försöka skicka en annan lokal gren till samma fjärrgren.

Förmodligen är servern redan konfigurerad med receive.denyDeletes och receive.denyNonFastForwards för att upprätthålla den policyn, så det enda oavsiktliga du kan försöka fånga är ombasering av incheckningar som redan har skickats upp.

Här är ett exempel på ett pre‑ombasering‑skript som kontrollerar det. Det får en lista över alla incheckningar du är på väg att skriva om och kontrollerar om de finns i någon av dina fjärrreferenser. Om den ser en incheckning som är nåbar från en av dina fjärrreferenser avbryter den ombaseringen.

#!/usr/bin/env ruby

base_branch = ARGV[0]
if ARGV[1]
  topic_branch = ARGV[1]
else
  topic_branch = "HEAD"
end

target_shas = `git rev-list #{base_branch}..#{topic_branch}`.split("\n")
remote_refs = `git branch -r`.split("\n").map { |r| r.strip }

target_shas.each do |sha|
  remote_refs.each do |remote_ref|
    shas_pushed = `git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`
    if shas_pushed.split("\n").include?(sha)
      puts "[POLICY] Commit #{sha} has already been pushed to #{remote_ref}"
      exit 1
    end
  end
end

Det här skriptet använder en syntax som inte togs upp i Revisionsurval. Du får en lista över incheckningar som redan har skickats upp genom att köra detta:

`git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}`

Syntaxen SHA^@ löses till alla föräldrar till den incheckningen. Du letar efter en incheckning som är nåbar från den senaste incheckningen i fjärrkodförrådet och som inte är nåbar från någon förälder till någon av SHA‑1:orna du försöker skicka – vilket betyder att det är en snabbspolning.

Den största nackdelen med detta angreppssätt är att det kan vara väldigt långsamt och ofta onödigt – om du inte försöker tvinga uppskickningen med -f kommer servern att varna dig och inte acceptera uppskickningen. Det är dock en intressant övning och kan i teorin hjälpa dig att undvika en ombasering som du senare måste gå tillbaka och fixa.