Een voorbeeld van Git-afgedwongen beleid
In dit gedeelte zul je gebruiken wat je geleerd hebt om een Git werkwijze vast te leggen, die controleert op een eigengemaakt commit boodschap formaat, afdwingt om alleen fast-forward pushes te accepteren, en alleen bepaalde gebruikers toestaat om bepaalde submappen te wijzigen in een project. Je zult client scripts maken die de ontwikkelaar helpen er achter te komen of hun push geweigerd zal worden, en server scripts die het beleid afdwingen.
Ik heb Ruby gebruikt om ze te schrijven, zowel omdat het mijn voorkeur script taal is en omdat ik vind dat het de meest pseudo code uitziende taal is van de scripttalen; dus je zou in staat moeten zijn om de code redelijk te kunnen volgen zelfs als je geen Ruby gebruikt. Maar, iedere taal zal prima werken. Alle voorbeeld haak scripts die met Git meegeleverd worden zijn Perl of Bash scripts, dus je kunt ook genoeg voorbeelden van haken in die talen zijn door naar de voorbeelden te kijken.
Server-kant haak
Al het werk aan de server kant zal in het update bestand in je haken map gaan. Het update bestand zal eens per gepushte branch uitgevoerd worden en aanvaardt de referentie waarnaar gepushed wordt, de oude revisie waar die branch was, en de nieuwe gepushte revisie. Je hebt ook toegang tot de gebruiker die de push doet, als de push via SSH gedaan wordt. Als je iedereen hebt toegestaan om connectie te maken als één gebruiker (zoals “git”) via publieke sleutel authenticatie, dan moet je misschien die gebruiker een shell wrapper geven die bepaalt welke gebruiker er connectie maakt op basis van de publieke sleutel, en een omgevingsvariabele instelt met daarin die gebruiker. Hier ga ik er vanuit dat de gebruiker in de $USER
omgevingsvariabele staat, dus begint je update script met het verzamelen van alle gegevens die het nodig heeft:
#!/usr/bin/env ruby
$refname = ARGV[0]
$oldrev = ARGV[1]
$newrev = ARGV[2]
$user = ENV['USER']
puts "Enforcing Policies... \n(#{$refname}) (#{$oldrev[0,6]}) (#{$newrev[0,6]})"
Ja, ik gebruik een globale variabele. Veroordeel me niet – het is makkelijker om het op deze manier te laten zien.
Een specifiek commit-bericht formaat afdwingen
Je eerste uitdaging is afdwingen dat ieder commit bericht moet voldoen aan een specifiek formaat. Om maar een doel te hebben, gaan we er vanuit dat ieder bericht een stuk tekst bevat dat eruit ziet asl “ref: 1234”, omdat je wil dat iedere commit gekoppeld is aan een werkonderdeel in je ticket systeem. Je moet kijken naar iedere commit die gepushed wordt, zien dat die tekst in de commit boodschap zit, en als de tekst niet in één van de commits zit, met niet nul eindigen zodat de push geweigerd wordt.
Je kunt de lijst met alle SHA-1 waarden van alle commits die gepushed worden verkrijgen door de $newrev
en $oldrev
waarden te pakken en ze aan een Git sanitaire voorzieningen commando genaamd git rev-list
te geven. Dit is eigenlijk het git log
commando, maar standaard voert het alleen de SHA-1 waarden uit en geen andere informatie. Dus, om een lijst te krijgen van alle commit SHA’s die worden geïntroduceerd tussen één commit SHA en een andere, kun je zoiets als dit uitvoeren:
$ git rev-list 538c33..d14fc7
d14fc7c847ab946ec39590d87783c69b031bdfb7
9f585da4401b0a3999e84113824d15245c13f0be
234071a1be950e2a8d078e6141f5cd20c1e61ad3
dfa04c9ef3d5197182f13fb5b9b1fb7717d2222a
17716ec0f1ff5c77eff40b7fe912f9f6cfd0e475
Je kunt die uitvoer pakken, door ieder van die commit SHA’s heen lopen, de boodschap daarvan pakken, en die boodschap testen tegen een reguliere expressie die op een bepaald patroon zoekt.
Je moet uit zien te vinden hoe je de commit boodschap kunt krijgen van alle te testen commits. Om de rauwe commit gegevens te krijgen, kun je een andere sanitaire voorzieningen commando genaamd git cat-file
gebruiken. Ik zal al deze sanitaire voorzieningen commando’s behandelen in detail in Hoofdstuk 9; maar voor nu is dit wat het commando je geeft:
$ 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
changed the version number
Een simpele manier om de commit boodschap te krijgen van een commit waarvan je de SHA-1 waarde hebt, is naar de eerste lege regel gaan en alles wat daarna komt pakken. Je kunt dat doen met het sed
commando op Unix systemen:
$ git cat-file commit ca82a6 | sed '1,/^$/d'
changed the version number
Je kunt die spreuk gebruiken om de commit boodschap te pakken van iedere commit die probeert te worden gepushed en eindigen als je ziet dat er iets is wat niet past. Om het script te eindigen en de push te weigeren, eindig je met niet nul. De hele methode ziet er zo uit:
$regex = /\[ref: (\d+)\]/
# afgedwongen eigen commit bericht formaat
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
Door dat in je update
script te stoppen, zal het updates weigeren die commits bevatten die berichten hebben die niet aan je regel voldoen.
Een gebruiker-gebaseerd ACL systeem afdwingen
Stel dat je een mechanisme wil toevoegen dat gebruik maakt van een toegangscontrole lijst (ACL) die specificeert welke gebruikers zijn toegestaan om wijzigingen te pushen naar welke delen van je project. Sommige mensen hebben volledige toegang, en andere hebben alleen toegang om wijzigingen te pushen naar bepaalde submappen of specifieke bestanden. Om dit af te dwingen zul je die regels schrijven in een bestand genaamd acl
dat in je bare Git repository op de server leeft. Je zult de update
haak naar die regels laten kijken, zien welke bestanden worden geïntroduceerd voor alle commits die gepushed worden, en bepalen of de gebruiker die de push doet toegang heeft om al die bestanden te wijzigen.
Het eerste dat je zult doen is je ACL schrijven. Hier zul je een formaat gebruiken dat erg lijkt op het CVS ACL mechanisme: het gebruikt een serie regels, waarbij het eerste veld avail
of unavail
is, het volgende veld een komma gescheiden lijst van de gebruikers is waarvoor de regel geldt, en het laatste veld het pad is waarvoor de regel geldt (leeg betekent open toegang). Alle velden worden gescheiden door een pipe (|
) karakter.
In dit geval heb je een aantal administrators, een aantal documentatie schrijvers met toegang tot de doc
map, en één ontwikkelaar die alleen toegang heeft tot de lib
en test
mappen, en je ACL bestand ziet er zo uit:
avail|nickh,pjhyett,defunkt,tpw
avail|usinclair,cdickens,ebronte|doc
avail|schacon|lib
avail|schacon|tests
Je begint met het lezen van deze gegevens in een structuur die je kunt gebruiken. In dit geval, om het voorbeeld eenvoudig te houden, zul je alleen de avail
richtlijnen handhaven. Hier is een methode die je een associatieve array geeft, waarbij de sleutel de gebruikersnaam is en de waarde een array van paden waar die gebruiker toegang tot heeft:
def get_acl_access_data(acl_file)
# lees ACL gegevens
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
Op het ACL bestand dat je eerder bekeken hebt, zal deze get_acl_access_data
methode een gegevens structuur teruggeven die er zo uit ziet:
{"defunkt"=>[nil],
"tpw"=>[nil],
"nickh"=>[nil],
"pjhyett"=>[nil],
"schacon"=>["lib", "tests"],
"cdickens"=>["doc"],
"usinclair"=>["doc"],
"ebronte"=>["doc"]}
Nu dat je de rechten bepaald hebt, moet je bepalen welke paden de commits die gepushed worden hebben aangepast, zodat je er zeker van kunt zijn dat de gebruiker die de push doet daar ook toegang tot heeft.
Je kunt eenvoudig zien welke bestanden gewijzigd zijn in een enkele commit met de --name-only
optie op het git log
commando (dat kort genoemd wordt in Hoofdstuk 2):
$ git log -1 --name-only --pretty=format:'' 9f585d
README
lib/test.rb
Als je gebruik maakt van de ACL structuur die wordt teruggegeven door de get_acl_access_data
methode en dat controleert met de bestanden in elk van de commits, dan kun je bepalen of de gebruiker toegang heeft om al hun commits te pushen:
# staat alleen bepaalde gebruikers toe om bepaalde submappen in een project te wijzigen
def check_directory_perms
access = get_acl_access_data('acl')
# zie of iemand iets probeert te pushen dat ze niet mogen
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 || # gebruiker heeft overal toegang tot
(path.index(access_path) == 0) # toegang tot dit pad
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
Het meeste daarvan zou makkelijk te volgen moeten zijn. Je krijgt een lijst met commits die gepushed worden naar je server met git rev-list
. Daarna vind je, voor iedere commit, de bestanden die aangepast worden en stelt vast of de gebruiker die pushed toegang heeft tot alle paden die worden aangepast. Een Ruby-isme dat wellicht niet duidelijk is is path.index(access_path) == 0
, wat waar is als het pad begint met access_path
– dit zorgt ervoor dat access_path
niet slechts in één van de toegestane paden zit, maar dat een toegestaan pad begint met ieder aangeraakt pad.
Nu kunnen je gebruikers geen commits pushen met slechte berichten of met aangepaste bestanden buiten hun toegewezen paden.
Fast-forward-only pushes afdwingen
Het enige overgebleven ding om af te dwingen is fast-forward-only pushes. In Git versie 1.6 of nieuwer, kun je de receive.denyDeletes
en receive.denyNonFastForwards
instellingen aanpassen. Maar dit afdwingen met behulp van een haak werkt ook in oudere versies van Git, en je kunt het aanpassen zodat het alleen gebeurd bij bepaalde gebruikers of wat je later ook verzint.
De logica om dit te controleren is zien of iedere commit die bereikbaar is vanuit de oudere revisie, niet bereikbaar is vanuit de nieuwere. Als er geen zijn, dan was het een fast-forward push; anders weiger je het:
# dwingt fast-forward only pushes af
def check_fast_forward
missed_refs = `git rev-list #{$newrev}..#{$oldrev}`
missed_ref_count = missed_refs.split("\n").size
if missed_ref_count > 0
puts "[POLICY] Cannot push a non fast-forward reference"
exit 1
end
end
check_fast_forward
Alles is ingesteld. Als je chmod u+x .git/hooks/update
uitvoert, wat het bestand is waarin je al deze code gestopt hebt, en dan probeert te pushen naar een non-fast-forwarded referentie, krijg je zoiets als dit:
$ 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] Cannot push a non-fast-forward reference
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'
Er zijn hier een aantal interessante dingen. Ten eerste, zie je dit als de haak start met uitvoeren.
Enforcing Policies...
(refs/heads/master) (fb8c72) (c56860)
Zie dat je dat afgedrukt hebt naar stdout aan het begin van je update script. Het is belangrijk om te zien dat alles dat je script naar stdout uitvoert, naar de client overgebracht wordt.
Het volgende dat je op zal vallen is de foutmelding.
[POLICY] Cannot push a non fast-forward reference
error: hooks/update exited with error code 1
error: hook declined to update refs/heads/master
De eerste regel was door jou afgedrukt, de andere twee waren Git die je vertelde dat het update script met niet nul eindigde en dat dat hetgeen is dat je push weigerde. Als laatste heb je dit:
To git@gitserver:project.git
! [remote rejected] master -> master (hook declined)
error: failed to push some refs to 'git@gitserver:project.git'
Je zult een remote weiger bericht zien voor iedere referentie die je haak weigerde, en het verteld je dat het specifiek was geweigerd omdat het een haak fout was.
Daarnaast zul je een foutmelding zien dat je uitvoert als de ref marker niet in één van je commits zit.
[POLICY] Your message is not formatted correctly
Of als iemand een bestand probeert aan te passen waar ze geen toegang tot hebben en een commit probeert te pushen waar het in zit, dan zullen ze iets vergelijkbaars zien. Bijvoorbeeld, als een documentatie schrijver een commit probeert te pushen dat iets wijzigt dat in de lib
map zit, dan zien ze
[POLICY] You do not have access to push to lib/test.rb
Dat is alles. Vanaf nu, zolang als het update
script aanwezig en uitvoerbaar is, zal je repository nooit teruggedraaid worden en zal nooit een commit bericht zonder je patroon erin bevatten, en je gebruikers zullen ingeperkt zijn.
Client-kant haken
Het nadeel hiervan is het zeuren dat geheid zal gebeuren zodra de commits van je gebruikers geweigerd worden. Het feit dat hun zorgzaam vervaardigde werk geweigerd wordt op het laatste moment kan enorm frustrerend en verwarrend zijn: daarnaast, zullen ze hun geschiedenis moeten aanpassen om het te corrigeren, wat niet altijd voor de mensen met een zwak hart is.
Het antwoord op dit dilemma is een aantal client-kant haken te leveren, die gebruikers kunnen toepassen om hen te waarschuwen dat ze iets doen dat de server waarschijnlijk gaat weigeren. Op die manier kunnen ze alle problemen corrigeren voordat ze gaan committen en voordat die problemen moeilijk te herstellen zijn. Omdat haken niet overgebracht worden bij een clone van een project, moet je deze scripts op een andere manier distribueren en je gebruikers ze dan in hun .git/hooks
map laten zetten en ze uitvoerbaar maken. Je kunt deze haken in je project of in een apart project distribueren, maar er is geen manier om ze automatisch in te laten stellen.
Om te beginnen zou je je commit boodschap moeten controleren, vlak voordat iedere commit opgeslagen wordt, zodat je weet dat de server je wijzigingen niet gaat weigeren omdat de commit boodschap een verkeerd formaat heeft. Om dit te doen, kun je de commit-msg
haak toevoegen. Als je dat de commit boodschap laat lezen uit het bestand dat als eerste argument opgegeven wordt, en dat vergelijkt met het patroon, dan kun je Git forceren om de commit af te breken als er geen overeenkomst is:
#!/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
Als dat script op z’n plaats staat (in .git/hooks/commit-msg
) en uitvoerbaar is, en je commit met een verkeerd formaat bericht, dan zie je dit:
$ git commit -am 'test'
[POLICY] Your message is not formatted correctly
In dat geval was er geen commit afgerond. Maar, als je bericht het juiste patroon bevat, dan staat Git je toe te committen:
$ git commit -am 'test [ref: 132]'
[master e05c914] test [ref: 132]
1 files changed, 1 insertions(+), 0 deletions(-)
Vervolgens wil je er zeker van zijn dat je geen bestanden buiten je ACL scope aanpast. Als de .git
map van je project een kopie van het ACL bestand bevat dat je eerder gebruikte, dan zal het volgende pre-commit
script die beperkingen op je toepassen:
#!/usr/bin/env ruby
$user = ENV['USER']
# [ insert acl_access_data method from above ]
# staat alleen bepaalde gebruikers toe bepaalde mappen aan te passen
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
Dit is ongeveer hetzelfde script als aan de server kant, maar met twee belangrijke verschillen. Als eerste staat het ACL bestand op een andere plek, omdat dit script vanuit je werkmap draait, niet vanuit je Git map. Je moet het pad naar het ACL bestand wijzigen van dit
access = get_acl_access_data('acl')
in dit:
access = get_acl_access_data('.git/acl')
Het andere belangrijke verschil is de manier waarop je een lijst krijgt met bestanden die je gewijzigd hebt. Omdat de server kant methode naar de log van commits kijkt, en op dit punt je commit nog niet opgeslagen is, moet je je bestandslijst in plaats daarvan uit het stage gebied halen. In plaats van
files_modified = `git log -1 --name-only --pretty=format:'' #{ref}`
moet je dit gebruiken
files_modified = `git diff-index --cached --name-only HEAD`
Maar dat zijn de enige twee verschillen – voor de rest werkt het script op dezelfde manier. Een instinker is dat het van je verwacht dat je lokaal werkt als dezelfde gebruiker die pushed naar de remote machine. Als dat verschillend is, moet je de $user
variabele handmatig instellen.
Het laatste ding dat je moet doen is controleren dat je niet non-fast-forward referenties probeert te pushen, maar dat komt minder voor. Om een referentie te krijgen dat geen fast-forward is, moet je voorbij een commit rebasen die je al gepushed hebt, of een andere lokale branch naar dezelfde remote branch proberen te pushen.
Omdat de server je zal vertellen dat je geen non-fast-forward push kunt doen, en de haak de push tegenhoudt, is het enige ongelukkige ding dat je kunt proberen te vangen het rebasen van commits die je al gepushed hebt.
Hier is een voorbeeld pre-rebase script dat daarop controleert. Het haalt een lijst met alle commits die je op het punt staat te herschrijven, en controleert of ze al op een bepaalde manier bestaan in één van je remote referenties. Als het er een ziet die bereikbaar is vanuit een van je remote referenties, dan stopt het de rebase:
#!/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
Dit script gebruikt een syntax dat niet behandeld is in de Revisie Selectie sectie van Hoofdstuk 6. Je krijgt een lijst van commits die al gepushed zijn door dit uit te voeren:
git rev-list ^#{sha}^@ refs/remotes/#{remote_ref}
De SHA^@
syntax wordt vervangen door alle ouders van die commit. Je bent aan het kijken naar iedere commit die bereikbaar is vanuit de laatste commit op de remote en die niet bereikbaar is vanuit elke ouder van de SHA’s die je probeert te pushen – wat betekend dat het een fast-forward is.
Het grote nadeel van deze aanpak is dat het erg traag kan zijn en vaak onnodig is – als je de push niet probeert te forceren met de -f
optie, dan zal de server je proberen te waarschuwen en de push niet accepteren. Maar, het is een aardige oefening en kan je in theorie helpen om een rebase te omzeilen die je later zult moeten herstellen.