Git
Chapters ▾ 2nd Edition

8.4 Прилагођавање програма Гит - Пример полисе коју спроводи програм Гит

Пример полисе коју спроводи програм Гит

У овом одељку ћете искористити оно што сте до сада научили да успоставите процес рада са програмом Гит који проверава да ли је испуњен одређени формат комит поруке и само одређеним корисницима дозвољава да мењају одређене поддиректоријуме у пројекту. Направићете клијентске скрипте које помажу програмеру да зна да ли ће подаци које жели да гурне бити прихваћени или не и серверску скрипту која заправо спроводи полисе.

Скрипте које ћемо представити су писане у Рубију; делимично због наше интелектуалне инерције, али и због тога што се Руби лако чита, иако не мора да значи да њему нешто умете и да напишете. Међутим, сваки језик ће функционисати — све скрипте које се испоручују уз програм Гит су или на Перлу или на Бешу, па ако их погледате видећете и пуно примера кука на овим језицима.

Кука на серверској страни

Сав рад на серверској страни ће се налазити у фајлу update у директоријуму hooks. Кука update се покреће по једном за сваку грану која се гура и узима три аргумента:

  • име референце на коју се гура,

  • стара ревизија где се грана раније налазила и

  • нова ревизија која се гура.

Сем тога, имате и приступ кориснику који обавља гурање ако се оно обавља преко SSH протокола. Ако сте дозволили свакоме да се повеже као један корисник (нпр. „git”) преко аутентификације јавним кључем, можда ћете морати да том кориснику дате омотач љуске који на основу јавног кључа одређује који се корисник повезао, па да сходно томе, подесите променљиву окружења. Овде ћемо претпоставити да се корисник који се повезује налази у променљивој окружења $USER, тако да скрипта update почиње тако што прикупља све информације које су вам потребне:

#!/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]})"

Да, то су глобалне променљиве. Не осуђујте нас — лакше је показати пример тако.

Спровођење одређеног формата за комит поруке

Први изазов је наметнути полису која прописује да свака комит порука мора да буде у одређеном формату. Чисто да бисмо имали неки циљ, претпоставићемо да свака порука мора да садржи стринг који изгледа као „ref: 1234”, јер желите да сваки комит буде повезан са неким тикетом. Морате да прегледате сваки комит који се гура, да испитате да ли се тај стринг налази у комит поруци, па ако недостаје у макар једном комиту, завршите извршавање и вратите вредност различиту од нуле како бисте одбили гурање.

Можете да добити листу SHA-1 вредности свих комитова који се гурају тако што ћете узети вредности $newref и $oldref и проследити их водоводној команди програма Гит git rev-list. Ово је у суштини команда git log, али подразумевано исписује само SHA-1 вредности и никакве друге информације. Дакле, да бисте добили листу SHA-1 вредности свих комитова који су уведени између два комита задата својим SHA-1 вредностима, извршите нешто овако:

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

Можете да узмете тај излаз, да прођете петљом кроз сваку од ових SHA-1 вредности комита, узмете поруку за њега, и тестирате је регуларним изразом који тражи одговарајући шаблон.

Морате пронаћи начин да добијете комит поруку сваког од ових комитова како бисте је тестирали. За добијање сирових података о комиту, можете употребити још једну водоводну команду под именом git cat-file. У Гит изнутра ћемо детаљније прећи ове водоводне команде; засад, ево шта вам она враћа:

$ 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

Једноставан начин да добијете комит поруку из комита када имате његов SHA-1 јесте да одете на прву празну линију и узмете све после тога. То можете урадити командом sed на Јуникс системима:

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

Ту чаролију можете употребити да зграбите комит поруку из сваког комита који покушава да буде гурнут, па да завршите извршавање ако видите нешто што се не уклапа. Да бисте завршили извршавање скрипте и одбили то гурање, вратите излазну вредност различиту од нуле. Цела метода изгледа овако:

$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

Ако ово поставите у скрипту update, биће одбијено гурање података који садрже комитове чије се поруке не задовољавају ваше правило.

Спровођење ACL система базираног на корисницима

Рецимо да желите додати механизам који користи листу за контролу приступа (ACL) која одређује којим корисницима је дозвољено гурање промена на које делове пројекта. Неки људи могу имати потпун приступ, а други само могу да гурају промене одређених поддиректоријума или одређених фајлова. Да бисте спровели овакву полису, та правила морате да запишете у фајл под именом acl који треба да сместите у огољени Гит репозиторијум на серверу. Кука update ће читати та правила, видеће које фајлове уводе сви комитови који се гурају и одредиће да ли корисник који врши гурање има приступ да ажурира све те фајлове.

Прва ствар коју треба да урадите јесте да напишете ACL. Овде ћемо користити формат који веома личи на CVS ACL механизам: користи низ линија, где је прво поље avail или unavail, следеће поље је листа корисника који за које важи правило раздвојена зарезима, док је последње поље путања за коју важи правило (при чему празно значи отворени приступ). Ова поља су раздвојена карактером вертикална црта (|).

У овом случају, имате неколико администратора, неке људе задужене за документацију који имају приступ директоријуму doc и једног програмера који има приступ само директоријумима lib и tests, па онда ваш ACL изгледа овако:

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

Почињете учитавањем ових података у структуру коју можете да искористите. У овом случају, да би пример био једноставан, спровешћете само avail директиве. Ево методе која вам има асоцијативни низ у којем је кључ корисничко име, а вредност је низ путања у којима тај корисник има дозволу за писање:

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

Када се метода get_acl_access_data позове над ACL фајлом који сте видели раније, она враћа структуру података која изгледа овако:

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

Сада када сте средили питање дозвола, треба да одредите које то путање мењају комитови који се гурају, тако да бисте били сигурни да корисник има приступ свима тим путањама.

Фајлове које мења један комит прилично лако можете да видите користећи опцију --name-only команде git log (која је кратко поменута у Основе програма Гит).

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

README
lib/test.rb

Ако употребите ACL структуру коју је вратила метода get_acl_access_data и упоредите је са фајловима излистаним за сваки од комитова, можете одредити да ли корисник има право да гурне све своје комитове:

# дозвољава само одређеним корисницима да измене одређене поддиректоријуме у пројекту
def check_directory_perms
  access = get_acl_access_data('acl')

  # провери да ли неко покушава да гурне промене које не сме
  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  # корисник има приступ свему
           || (path.start_with? access_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

Командом git rev-list добијате листу нових комитова који се гурају на ваш сервер. Онда, за сваки од тих комитова проналазите фајлове који се мењају и постарате се да корисник који гура има приступ свим путањама које се мењају.

Сада ваши корисници не могу да гурају комитове са лоше форматираним порукама или са променама које мењају фајлове ван путања које су им додељене.

Тестирање

Ако извршите chmod u+x .git/hooks/update, што је фајл у који би требало да поставите сав овај кôд, па онда пробате да гурнете комит са поруком која не задовољава полису, добићете нешто овако:

$ 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'

Овде има неколико занимљивих ствари. Најпре, можете видети где се тачно покреће кука.

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

Присетите се да се то исписали на самом почетку update скрипте. Све што ваша скрипта испише на stdout се преноси клијенту.

Следећа ствар коју можете уочити јесу поруке о грешкама.

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

Прву линију сте ви исписали, а остале две програм Гит који вам говори да је скрипта update завршила извршавање и вратила вредност различиту од нуле и да је то разлог због чега гурање није успело. На крају, ту је и ово:

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

Видећете remote rejected поруку за сваку референцу коју је кука одбила и то вам говори да је ваш захтев одбијен зато што се кука није извршила успешно.

Штавише, ако неко покуша да измени фајл којем нема приступ, па онда гурне комит који га садржи, видеће нешто слично. На пример, ако аутор документације покуша да гурне комит који мења нешто у директоријуму lib, видеће следеће:

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

Одсад па надаље, све док је скрипта update на свом месту и може да се изврши, репозиторијум никада неће имати комит поруку која у себи нема ваш шаблон, а ваши корисницима ће приступ деловима пројекта бити прецизно одређен.

Куке на страни клијента

Лоша страна овог приступа су притужбе које ће се несумњиво јавити када комитови ваших корисника не буду прихваћени. Кад се рад у који је уложено пуно труда одбије у последњем тренутку, то зна да буде доста фрустрирајуће и збуњујуће; штавише, мораће да измене своју историју како би исправили проблем, а то није увек посао за оне са слабим срцем.

Одговор на ову дилему је постављање неких кука на страни клијента које корисници могу да покрену и које ће их обавестити да раде нешто што ће сервер вероватно одбити. На тај начин могу да реше проблем пре него што комитују и пре него што ти проблеми постану тежи за решавање. Пошто се неке куке не преносе заједно са клоном пројекта, те скрипте морате дистрибуирати на неки други начин и да објасните корисницима да их сместе у .git/hooks директоријум и да их учине извршним. Ове куке можете да дистрибуирате у оквиру пројекта или у посебном пројекту, али програм Гит их неће аутоматски поставити.

За почетак, треба да проверите комит поруку пре него што се сваки комит забележи, како бисте знали да вам сервер неће одбити промене због лоше форматираних комит порука. Да бисте урадили ово, можете додати commit-msg куку. Ако је подесите тако да чита поруку из фајла који се проследи као први аргумент, па онда упореди то са шаблоном, програм Гит можете навести да обустави креирање комита ако се не пронађе подударање:

#!/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

Ако се та скрипта налази на одговарајућем месту (у .git/hooks/commit-msg) и подеси као извршни фајл, а направите комит са поруком која није исправно форматирана, видећете следеће:

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

У том случају се комит не довршава. Међутим, ако комит порука садржи одговарајући шаблон, програм Гит вам дозвољава да креирате комит:

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

Затим желите да се осигурате да не мењате фајлове који су ван вашег ACL опсега важења. Ako .git direktorijum vašeг projektа sadrži kopiju ACL фајла koju ste ranije koristili, onda će sledeća pre-commit skripta спровести ограничења која су вам наметнута:

#!/usr/bin/env ruby

$user    = ENV['USER']

# [ убаците овде методу acl_access_data одозго ]

# дозволи само одређеним корисницима да мењају одређене поддиректоријуме пројекта
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

Ово је отприлике иста скрипта као и она на серверској страни, али уз две кључне разлике. Прво, ACL фајл се налази на другом месту, јер се ова скрипта покреће из вашег радног директоријума, а не из .git директоријума. Морате да промените путању до ACL фајла са:

access = get_acl_access_data('acl')

на следеће:

access = get_acl_access_data('.git/acl')

Још једна важна разлика је начин на који добијате листу свих фајлова који су промењени. Пошто метода на серверској страни гледа лог комитова, а у овом тренутку комит још увек није забележен, листу фајлова морате да узмете из стејџа. Уместо:

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

морате да користите:

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

Али ово су и једине две разлике – сем тога, скрипта ради на потпуно исти начин. Једина зачкољица је у томе што скрипта очекује да се извршава локално као исти корисник који ће обавља гурање на удаљену машину. Ако то није случај, морате да ручно подесите променљиву $user.

Још једна ствар коју овде можемо да урадимо јесте да се постарамо да корисник не сме да гурне референце које нису брзо премотавање унапред. Да бисте добили референцу која није премотавање унапред, морате или да ребазирате прошли комит који сте већ гурнули или да покушате гурање различите локалне грану на исту удаљену.

Претпоставља се да је сервер већ конфигурисан са receive.denyDeletes и receive.denyNonFastForwards које спроводе ову полису, тако да је једина случајна ствар коју можете покушати да ухватите ребазирање комитова који су већ раније гурнути.

Ево примера pre-base скрипте која ради управо то. Преузима листу свих комитова које планирате да поново испишете и проверава да ли постоје на било којој од ваших удаљених референци. Ако види барем један до којег може да се стигне из једне од ваших удаљених референци, прекида ребазирање.

#!/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

Ова скрипта користи синтаксу која није обрађена у Избор ревизија. Овако добијате листу комитова који су већ раније гурнути:

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

Синтакса SHA^@ се разрешава на све родитеље тог комита. Тражите било који комит до којег може да се стигне из последњег комита на удаљеном репозиторијуму и до којег не може да се стигне од било ког родитеља било којих SHA-1 које покушавате да гурнете – што значи да се ради о брзом премотавању унапред.

Главни недостатак овог приступа је што уме да буде веома спор и често непотребан — ако не покушавате принудно гурање заставицом -f, сервер ће вас упозорити и неће прихватити гурнуте комитове. Ипак, ово је занимљива вежба и теоретски вам може помоћи да избегнете ребазирање на које ћете касније морати да се вратите и да га исправите.