gitlabgitlab-api

How to use curl to post a linting request with the contents of .gitlab-ci.yml to the gitlab api?


Trying to make a curl request to gitlab.com api for linting .gitlab-ci.yaml file but receiving bad request response: {"status":400,"error":"Bad Request"}

#!/usr/bin/env bash


PAYLOAD=$( cat << JSON 
{ "content":
$(<$PWD/../.gitlab-ci.yml)
JSON
)

echo "Payload is $PAYLOAD"

curl --include --show-error --request POST --header "Content-Type: application/json" --header "Accept: application/json" "https://gitlab.com/api/v4/ci/lint" --data-binary "$PAYLOAD"

Has anyone managed to successfully lint a .gitlab-ci.yml via a bash script? Also tried wrapping the content payload in braces and receive same response.

Update

I think what is happening is that the GitLab CI endpoint expects the contents of the .gitlab-ci yaml file to be converted to json for the POST request. See here

Modifed the script to use ruby to convert yaml to json before sending and this works for simple .gitlab-ci.yml. However when using the yaml file for my project it gives an error: {"status":"invalid","errors":["(\u003cunknown\u003e): did not find expected ',' or ']' while parsing a flow sequence at line 1 column 221"]}% When I use the gitlab web page for linting the file is valid.

{"content": "{ \"stages\": [ \"build\", \"test\", \"pages\", \"release\" ], \"variables\": { \"DOCKER_DRIVER\": \"overlay2\" }, \"services\": [ \"docker:19.03.11-dind\" ], \"build:plugin\": { \"image\": \"docker:19.03.11\", \"stage\": \"build\", \"before_script\": [ \"echo \"$CI_JOB_TOKEN\" | docker login -u gitlab-ci-token --password-stdin \"$CI_REGISTRY\"\" ].....

Column 221 is \"image\": \"docker:19.03.11\" in the above json extract, specifically at the closing escaped quote. Think it is a problem with incorrectly escaped quotes??

#!/usr/bin/env bash

json=$(ruby -ryaml -rjson -e 'puts JSON.pretty_generate(YAML.load(ARGF))' < .gitlab-ci.yml)

# escape quotes
json_content=$(echo $json | perl -pe 's/(?<!\\)"/\\"/g')


# Add object contect for GitLab linter
json_content='{"content": "'${json_content}'"}'

echo "${json_content}"

curl --include --show-error --request POST \
    --header "Content-Type: application/json" \
    --header "Accept: application/json" \
    "https://gitlab.com/api/v4/ci/lint" \
    --data-binary "$json_content"

Second Update

Using the above bash script this yaml file:

stages:
  - test
test:
  stage: test
  script:
    - echo "test"

gets converted to this json:

{"content": "{ \"stages\": [ \"test\" ], \"test\": { \"stage\": \"test\", \"script\": [ \"echo \"test\"\" ] } }"}

When this is sent to the api receive the following json error response:

{"status":"invalid","errors":["(\u003cunknown\u003e): did not find expected ',' or ']' while parsing a flow sequence at line 1 column 62"]}% 

Solution

  • Got it working finally using the following script:

    #!/usr/bin/env bash
    
    json=$(ruby -ryaml -rjson -e 'puts(YAML.load(ARGF.read).to_json)' custom_hooks/valid.yml)
    
    # escape quotes
    json_content=$(echo $json | python -c 'import json,sys; print(json.dumps(sys.stdin.read()))')
    echo $json_content
    
    # Add object contect for GitLab linter
    json_content="{\"content\": ${json_content}}"
    
    # Output escaped content to file
    echo $json_content > custom_hooks/input.json
    echo "Escaped json content written to file input.json"
    
    curl --include --show-error --request POST \
        --header "Content-Type: application/json" \
        --header "Accept: application/json" \
        "https://gitlab.com/api/v4/ci/lint" \
        --data-binary "$json_content"
    

    N.B will be tweaking script to read file from system args rather than the fixed file location custom_hooks/valid.yml. Also the JSON response needs parsing using jq or python / ruby command shell. Including this script on the offchance that it will help others.

    The problem was that initially I was sending YAML contents of the file directly to the api:

    { "content": { <contents of .gitlab-yml> } }
    

    It looks as though GitLab accepts YAML converted to an escaped JSON string in their API. So used ruby to convert the yaml to JSON and then used python to escape the resulting JSON produced by ruby. Finally was able to use curl to send the escaped JSON string to the GitLab API for validating.....

    Not sure if Ruby has something equivalent to python's json.dumps .... but this solution allows me to validate gitlab-ci....Next stage hookup to git pre-commit hooks / server side pre-receive (if possible!) to prevent invalid .gitlab-ci.yml files breaking CI pipeline.

    Newbie to ruby...since posting original answer have had a go at creating a ruby script that can be used from pre-commit hooks etc. Now only require bash and ruby:

    #!/usr/bin/env ruby
    
    
    require 'json'
    require 'net/http'
    require 'optparse'
    require 'yaml'
    
    
    =begin
    POST to GitLab api for linting ci yaml
    Params:
    +url+ :: Api url
    +yaml+ :: Yaml payload for linting
    Returns:
    Json validation result from API for HTTP response Success
    Aborts with HTTP Message for all other status codes
    =end
    def call_api(url, yaml)
        uri = URI.parse(url)
        
        req = Net::HTTP::Post.new(uri)
        req.content_type='application/json'
        req['Accept']='application/json'
        req.body = JSON.dump({"content" => yaml.to_json})
        
        https = Net::HTTP.new(uri.host, uri.port)
        https.use_ssl = true
        https.verify_mode = OpenSSL::SSL::VERIFY_PEER
        
        response = https.request(req)
    
        case response
            when Net::HTTPSuccess
                puts "request successful"
                return JSON.parse response.body
            when Net::HTTPUnauthorized
                abort("#{response.message}: invalid token in api request?")
            when Net::HTTPServerError
                abort('error' => "#{response.message}: server error, try again later?")
            when Net::HTTPBadRequest
                puts "Bad request..." + request.body
                abort("#{response.message}: bad api request?")
            when Net::HTTPNotFound
                abort("#{response.message}: api request not found?")
            else
                puts "Failed validation\nJSON payload :: #{request.body}\nHTTP Response: #{response.message}"
                abort("#{response.message}: failed api request?")
        end
    end
    
    
    =begin
    Display exit report and raise the appropriate system exit code
    Params:
    +status+ :: Validation status string.  Legal values are valid or invalid
    +errors+ :: String array storing errors if yaml was reported as invalid
    Returns:
    Exits with 0 when successful
    Exits with 1 on validation errors or fails to parse legal status value
    =end
    def exit_report(status, errors)
        case status
            when "valid"
                puts ".gitlab-ci.yml is valid"
                exit(0)
            when "invalid"
                abort(".gitlab-ci.yml is invalid with errors:\n\n" + errors.join("\n"))
            else 
                abort("A problem was encountered parsing status : " + status)  
        end
    end
    
    
    =begin
    Load yaml file from path and return contents
    Params:
    +path+ :: Absolute or relative path to .gitlab-ci.yml file
    =end
    def load_yaml(path)
        begin
            YAML.load_file(path)
        rescue Errno::ENOENT
            abort("Failed to load .gitlab-ci.yml")
        end
    end
    
    =begin
    Parse command line options
    Returns:
    Hash containing keys: {:yaml_file,:url}
    =end
    def read_args()
        options = {}
        OptionParser.new do |opt|
            opt.on('-f', '--yaml YAML-PATH', 'Path to .gitlab-ci.yml') { |o| options[:yaml_file] = o }
            opt.on('-l', '--url GitLab url', 'GitLab API url') { |o| options[:url] = o }
        end.parse!
    
        options
    end
    
    =begin
    Load yaml to send to GitLab API for linting
    Display report of linting retrieved from api
    Returns:
    Exits with 0 upon success and 1 when errors encountered
    =end
    def main()
        # try and parse the arguments
        options = read_args()
        unless !options.has_key?(:yaml_file) || !options.has_key?(:url)
             # try and load the yaml from path
            puts "Loading file #{options[:yaml_file]}"
            yaml = load_yaml(options[:yaml_file])
    
            # make lint request to api
            puts "Making POST request to #{options[:url]}"
            response_data=call_api(options[:url], yaml)
    
            # display exit report and raise appropriate exit code
            unless !response_data.has_key?("status") || !response_data.has_key?("errors")
                exit_report response_data["status"], response_data["errors"]
            else
                puts "Something went wrong parsing the json response " + response_data
            end
        else
            abort("Missing required arguments yaml_file and url, use -h for usage")
        end
    end
    
    # start
    main