Files
ubicloud/spec/routes/runtime/github_spec.rb
Jeremy Evans 4b819d3cb2 Change all create_with_id to create
It hasn't been necessary to use create_with_id since
ebc79622df, in December 2024.

I have plans to introduce:

```ruby
def create_with_id(id, values)
  obj = new(values)
  obj.id = id
  obj.save_changes
end
```

This will make it easier to use the same id when creating
multiple objects.  The first step is removing the existing
uses of create_with_id.
2025-08-06 01:55:51 +09:00

419 lines
22 KiB
Ruby
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# frozen_string_literal: true
require_relative "spec_helper"
require "aws-sdk-s3"
require "octokit"
RSpec.describe Clover, "github" do
describe "authentication" do
let(:vm) { create_vm }
before { login_runtime(vm) }
it "vm has no runner" do
get "/runtime/github"
expect(last_response).to have_runtime_error(400, "invalid JWT format or claim in Authorization header")
end
it "vm has runner but no repository" do
GithubRunner.create(vm_id: vm.id, repository_name: "test", label: "ubicloud")
get "/runtime/github"
expect(last_response).to have_runtime_error(400, "invalid JWT format or claim in Authorization header")
end
it "vm has runner and repository" do
repository = GithubRepository.create(name: "test", access_key: "key")
GithubRunner.create(vm_id: vm.id, repository_name: "test", label: "ubicloud", repository_id: repository.id)
get "/runtime/github"
expect(last_response.status).to eq(404)
end
end
it "setups blob storage if no access key" do
vm = create_vm
login_runtime(vm)
repository = instance_double(GithubRepository, access_key: nil)
expect(GithubRunner).to receive(:[]).with(vm_id: vm.id).and_return(instance_double(GithubRunner, repository: repository))
expect(repository).to receive(:setup_blob_storage)
post "/runtime/github/caches"
expect(last_response).to have_runtime_error(400, "missing parameter for key")
end
it "handles errors when attempting to setup blob storage" do
vm = create_vm
login_runtime(vm)
repository = instance_double(GithubRepository, access_key: nil)
runner = instance_double(GithubRunner, repository: repository)
expect(GithubRunner).to receive(:[]).with(vm_id: vm.id).and_return(runner)
expect(runner).to receive(:ubid).and_return(nil)
expect(repository).to receive(:ubid).and_return(nil)
expect(repository).to receive(:setup_blob_storage).and_raise(Excon::Error::HTTPStatus.new("Expected(200) <=> Actual(520 Unknown)", nil, Excon::Response.new(body: "foo")))
post "/runtime/github/caches"
expect(last_response).to have_runtime_error(400, "unable to setup blob storage")
end
describe "cache endpoints" do
let(:repository) { GithubRepository.create(name: "test", default_branch: "main", access_key: "123", installation:) }
let(:installation) { GithubInstallation.create(installation_id: 123, name: "test-user", type: "User", project: Project.create(name: "test")) }
let(:runner) { GithubRunner.create(vm_id: create_vm.id, repository_name: "test", label: "ubicloud", repository_id: repository.id, workflow_job: {head_branch: "dev"}) }
let(:url_presigner) { instance_double(Aws::S3::Presigner, presigned_request: "aa") }
let(:blob_storage_client) { instance_double(Aws::S3::Client) }
before do
login_runtime(runner.vm)
allow(Aws::S3::Presigner).to receive(:new).and_return(url_presigner)
allow(Aws::S3::Client).to receive(:new).and_return(blob_storage_client)
end
describe "reserves cache" do
it "fails if one of the parameters are missing" do
[
[nil, "v1"],
["k1", nil]
].each do |key, version|
params = {key: key, version: version}.compact
post "/runtime/github/caches", params
expect(last_response).to have_runtime_error(400, /missing parameter for/)
end
end
it "fails if the runner doesn't have a scope" do
runner.update(workflow_job: nil)
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100}
expect(last_response).to have_runtime_error(400, "No workflow job data available")
end
it "fails if cache is bigger than 10GB" do
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 11 * 1024 * 1024 * 1024}
expect(last_response).to have_runtime_error(400, "The cache size is over the 10GB limit")
end
it "fails if the cache entry already exists before upload" do
GithubCacheEntry.create(key: "k1", version: "v1", scope: "dev", repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100}
expect(last_response).to have_runtime_error(409, "A cache entry for dev scope already exists with k1 key and v1 version.")
end
it "fails if the cache entry already exists after upload" do
s3_client = instance_double(Aws::S3::Client, create_multipart_upload: nil, delete_object: nil)
repository_id = repository.id
created_by = runner.id
expect(s3_client).to receive(:create_multipart_upload) do |key:, bucket:|
GithubCacheEntry.create(key: "k1", version: "v1", scope: "dev", repository_id:, created_by:, committed_at: Time.now)
Struct.new(:upload_id).new("1")
end
expect(s3_client).not_to receive(:delete_object)
expect(Aws::S3::Client).to receive(:new).with(anything).and_return(s3_client)
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100}
expect(last_response).to have_runtime_error(409, "A cache entry for dev scope already exists with k1 key and v1 version.")
end
it "rollbacks inconsistent cache entry if a failure occurs in the middle" do
expect(blob_storage_client).to receive(:create_multipart_upload).and_raise(CloverError.new(500, "UnexceptedError", "Sorry, we couldnt process your request because of an unexpected error."))
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 75 * 1024 * 1024}
expect(last_response).to have_runtime_error(500, "Sorry, we couldnt process your request because of an unexpected error.")
expect(repository.cache_entries).to be_empty
end
it "gets branch from GitHub API if the runner doesn't have a branch info" do
runner.update(workflow_job: nil, installation_id: installation.id)
client = instance_double(Octokit::Client)
allow(Github).to receive(:installation_client).and_return(client)
expect(client).to receive(:workflow_run_jobs).and_return({jobs: [{head_branch: "dev", runner_name: runner.ubid}]})
expect(blob_storage_client).to receive(:create_multipart_upload).and_return(instance_double(Aws::S3::Types::CreateMultipartUploadOutput, upload_id: "upload-id"))
expect(url_presigner).to receive(:presigned_url).with(:upload_part, hash_including(bucket: repository.bucket_name, upload_id: "upload-id"))
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100, runId: 123}
expect(last_response.status).to eq(200)
expect(repository.cache_entries.first.scope).to eq("dev")
end
it "returns presigned urls and upload id for the reserved cache" do
expect(blob_storage_client).to receive(:create_multipart_upload).and_return(instance_double(Aws::S3::Types::CreateMultipartUploadOutput, upload_id: "upload-id"))
expect(url_presigner).to receive(:presigned_url).with(:upload_part, hash_including(bucket: repository.bucket_name, upload_id: "upload-id")) do |_, params|
"url-#{params[:part_number]}"
end.exactly(3).times
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 75 * 1024 * 1024}
expect(last_response.status).to eq(200)
response = JSON.parse(last_response.body)
expect(response["uploadId"]).to eq("upload-id")
expect(response["presignedUrls"]).to eq(["url-1", "url-2", "url-3"])
entry = repository.cache_entries.first
expect(entry.key).to eq("k1")
expect(entry.version).to eq("v1")
expect(entry.size).to eq(75 * 1024 * 1024)
expect(entry.upload_id).to eq("upload-id")
end
it "returns presigned urls and upload id for the reserved cache without size" do
expect(blob_storage_client).to receive(:create_multipart_upload).and_return(instance_double(Aws::S3::Types::CreateMultipartUploadOutput, upload_id: "upload-id"))
expect(url_presigner).to receive(:presigned_url).with(:upload_part, hash_including(bucket: repository.bucket_name, upload_id: "upload-id")) do |_, params|
"url-#{params[:part_number]}"
end.exactly(320).times
post "/runtime/github/caches", {key: "k1", version: "v1"}
expect(last_response.status).to eq(200)
response = JSON.parse(last_response.body)
expect(response["uploadId"]).to eq("upload-id")
expect(response["presignedUrls"].count).to eq(320)
entry = repository.cache_entries.first
expect(entry.key).to eq("k1")
expect(entry.version).to eq("v1")
expect(entry.size).to be_nil
expect(entry.upload_id).to eq("upload-id")
end
it "fails if can not authorize multipart upload" do
expect(blob_storage_client).to receive(:create_multipart_upload).with(hash_including(bucket: repository.bucket_name)).and_raise(Aws::S3::Errors::Unauthorized.new("error", "error")).exactly(3)
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100}
expect(last_response).to have_runtime_error(400, "Could not authorize multipart upload")
end
it "fails if the bucket does not yet exist" do
expect(blob_storage_client).to receive(:create_multipart_upload).with(hash_including(bucket: repository.bucket_name)).and_raise(Aws::S3::Errors::NoSuchBucket.new("error", "error")).exactly(3)
post "/runtime/github/caches", {key: "k1", version: "v1", cacheSize: 100}
expect(last_response).to have_runtime_error(400, "Could not authorize multipart upload")
end
end
describe "commits cache" do
it "fails if one of the parameters are missing" do
[
[["etag-1", "etag-2"], "upload-id", nil],
[nil, "upload-id", 100],
[["etag-1", "etag-2"], nil, 100]
].each do |etags, upload_id, size|
params = {etags: etags, uploadId: upload_id, size: size}.compact
post "/runtime/github/caches/commit", params
expect(last_response).to have_runtime_error(400, /missing parameter for /)
end
end
it "fails if there is no cache entry to commit" do
post "/runtime/github/caches/commit", {etags: ["etag-1", "etag-2"], uploadId: "upload-id", size: 100}
expect(last_response.status).to eq(204)
end
it "fails if there is no created multipart upload at blob storage" do
GithubCacheEntry.create(key: "cache-key", version: "key-version", scope: "dev", repository_id: repository.id, created_by: runner.id, upload_id: "upload-id", size: 100)
expect(blob_storage_client).to receive(:complete_multipart_upload).and_raise(Aws::S3::Errors::NoSuchUpload.new("error", "error"))
post "/runtime/github/caches/commit", {etags: ["etag-1", "etag-2"], uploadId: "upload-id", size: 100}
expect(last_response).to have_runtime_error(400, "Wrong parameters")
end
it "fails if the s3 storage service is unavailable" do
GithubCacheEntry.create(key: "cache-key", version: "key-version", scope: "dev", repository_id: repository.id, created_by: runner.id, upload_id: "upload-id", size: 100)
expect(blob_storage_client).to receive(:complete_multipart_upload).and_raise(Aws::S3::Errors::ServiceUnavailable.new("error", "error"))
post "/runtime/github/caches/commit", {etags: ["etag-1", "etag-2"], uploadId: "upload-id", size: 100}
expect(last_response).to have_runtime_error(503, "Service unavailable")
end
it "completes multipart upload" do
entry = GithubCacheEntry.create(key: "cache-key", version: "key-version", scope: "dev", repository_id: repository.id, created_by: runner.id, upload_id: "upload-id", size: 100)
expect(blob_storage_client).to receive(:complete_multipart_upload).with(
hash_including(upload_id: "upload-id", multipart_upload: {parts: [{etag: "etag-1", part_number: 1}, {etag: "etag-2", part_number: 2}]})
)
post "/runtime/github/caches/commit", {etags: ["etag-1", "etag-2"], uploadId: "upload-id", size: 100}
expect(last_response.status).to eq(200)
expect(entry.reload.committed_at).not_to be_nil
end
it "completes multipart upload without size" do
entry = GithubCacheEntry.create(key: "cache-key", version: "key-version", scope: "dev", repository_id: repository.id, created_by: runner.id, upload_id: "upload-id")
expect(blob_storage_client).to receive(:complete_multipart_upload).with(
hash_including(upload_id: "upload-id", multipart_upload: {parts: [{etag: "etag-1", part_number: 1}, {etag: "etag-2", part_number: 2}]})
)
post "/runtime/github/caches/commit", {etags: ["etag-1", "etag-2"], uploadId: "upload-id", size: 100}
expect(entry.reload.size).to eq(100)
expect(last_response.status).to eq(200)
expect(entry.reload.committed_at).not_to be_nil
end
end
describe "gets cache entry" do
it "fails if one of the parameters are missing" do
[
["k1,k2", nil],
[nil, "v1"],
["", "v1"]
].each do |keys, version|
params = {keys: keys, version: version}.compact
get "/runtime/github/cache", params
expect(last_response).to have_runtime_error(400, /missing parameter for |empty string provided for parameter keys/)
end
end
it "fails if no cache entry found" do
get "/runtime/github/cache", {keys: "k1", version: "v1"}
expect(last_response.status).to eq(204)
end
it "fails to get head branch if runner name not matched" do
runner.update(workflow_job: nil, installation_id: installation.id)
GithubCacheEntry.create(key: "k1", version: "v1", scope: "dev", repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
client = instance_double(Octokit::Client)
allow(Github).to receive(:installation_client).and_return(client)
expect(client).to receive(:workflow_run_jobs).and_return({jobs: [{head_branch: "dev", runner_name: "dummy-runner-name"}]})
get "/runtime/github/cache", {keys: "k1", version: "v1", runId: 123}
expect(last_response.status).to eq(204)
end
it "fails to get head branch if GitHub API raises an exception" do
runner.update(workflow_job: nil, installation_id: installation.id)
GithubCacheEntry.create(key: "k1", version: "v1", scope: "dev", repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
client = instance_double(Octokit::Client)
allow(Github).to receive(:installation_client).and_return(client)
expect(client).to receive(:workflow_run_jobs).and_raise(Octokit::NotFound)
get "/runtime/github/cache", {keys: "k1", version: "v1", runId: 123}
expect(last_response.status).to eq(204)
end
it "gets branch from GitHub API if the runner doesn't have a branch info" do
runner.update(workflow_job: nil, installation_id: installation.id)
entry = GithubCacheEntry.create(key: "k1", version: "v1", scope: "dev", repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
client = instance_double(Octokit::Client)
allow(Github).to receive(:installation_client).and_return(client)
expect(client).to receive(:workflow_run_jobs).and_return({jobs: [{head_branch: "dev", runner_name: runner.ubid}]})
expect(url_presigner).to receive(:presigned_url).with(:get_object, hash_including(bucket: repository.bucket_name, key: entry.blob_key)).and_return("http://presigned-url")
get "/runtime/github/cache", {keys: "k1", version: "v1", runId: 123}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body).slice("cacheKey", "cacheVersion", "scope").values).to eq(["k1", "v1", "dev"])
end
it "returns a cache from default branch when no branch info" do
runner.update(workflow_job: nil)
entry = GithubCacheEntry.create(key: "k1", version: "v1", scope: "main", repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
expect(url_presigner).to receive(:presigned_url).with(:get_object, hash_including(bucket: repository.bucket_name, key: entry.blob_key)).and_return("http://presigned-url")
get "/runtime/github/cache", {keys: "k1", version: "v1"}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body).slice("cacheKey", "cacheVersion", "scope").values).to eq(["k1", "v1", "main"])
end
it "returns the first matched cache with key for runner's branch" do
[
["k1", "v1", "dev"],
["k1", "v2", "main"],
["k2", "v1", "main"],
["k2", "v1", "dev"]
].each do |key, version, branch|
GithubCacheEntry.create(key: key, version: version, scope: branch, repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
end
expect(url_presigner).to receive(:presigned_url).with(:get_object, anything).and_return("http://presigned-url")
get "/runtime/github/cache", {keys: "k2", version: "v1"}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body).slice("cacheKey", "cacheVersion", "scope").values).to eq(["k2", "v1", "dev"])
expect(GithubCacheEntry[key: "k2", version: "v1", scope: "dev"].last_accessed_by).to eq(runner.id)
end
it "partially matched key returns the cache according to the order of incoming keys" do
GithubCacheEntry.create(key: "mix-dev-123", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now - 2, created_by: runner.id, committed_at: Time.now)
GithubCacheEntry.create(key: "mix-dev-main-123", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now - 1, created_by: runner.id, committed_at: Time.now)
GithubCacheEntry.create(key: "mix-prod-123", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now, created_by: runner.id, committed_at: Time.now)
expect(url_presigner).to receive(:presigned_url).with(:get_object, anything).and_return("http://presigned-url").at_least(:once)
get "/runtime/github/cache", {keys: "mix-dev-main-,mix-dev-,mix-", version: "v1"}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body).slice("cacheKey", "cacheVersion", "scope").values).to eq(["mix-dev-main-123", "v1", "main"])
expect(GithubCacheEntry[key: "mix-dev-main-123", version: "v1", scope: "main"].last_accessed_by).to eq(runner.id)
end
it "returns cache from any scope if scope check is disabled" do
GithubCacheEntry.create(key: "mix-dev-123", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now - 2, created_by: runner.id, committed_at: Time.now)
GithubCacheEntry.create(key: "mix-dev-main-123", version: "v1", scope: "feature-b", repository_id: repository.id, created_at: Time.now - 1, created_by: runner.id, committed_at: Time.now)
GithubCacheEntry.create(key: "mix-prod-123", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now, created_by: runner.id, committed_at: Time.now)
expect(url_presigner).to receive(:presigned_url).with(:get_object, anything).and_return("http://presigned-url").at_least(:once)
installation.project.set_ff_access_all_cache_scopes(true)
get "/runtime/github/cache", {keys: "mix-dev-main-,mix-dev-,mix-", version: "v1"}
expect(last_response.status).to eq(200)
expect(JSON.parse(last_response.body).slice("cacheKey", "cacheVersion", "scope").values).to eq(["mix-dev-main-123", "v1", "feature-b"])
expect(GithubCacheEntry[key: "mix-dev-main-123", version: "v1", scope: "feature-b"].last_accessed_by).to eq(runner.id)
end
it "only does a prefix match on key, escapes LIKE metacharacters in submitted keys" do
GithubCacheEntry.create(key: "k123456", version: "v1", scope: "main", repository_id: repository.id, created_at: Time.now, created_by: runner.id, committed_at: Time.now)
get "/runtime/github/cache", {keys: "%6", version: "v1"}
expect(last_response.status).to eq(204)
end
end
describe "lists cache entries" do
it "returns no content if the key is missing" do
get "/runtime/github/caches", {key: nil}
expect(last_response.status).to eq(204)
end
it "returns the list of cache entries for the key" do
[
["k1", "v1", "dev"],
["k1", "v2", "main"],
["k1", "v1", "feature"],
["k2", "v1", "dev"]
].each do |key, version, branch|
GithubCacheEntry.create(key: key, version: version, scope: branch, repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
end
get "/runtime/github/caches", {key: "k1"}
response = JSON.parse(last_response.body)
expect(response["totalCount"]).to eq(2)
expect(response["artifactCaches"].map { [it["cacheKey"], it["cacheVersion"]] }).to eq([["k1", "v1"], ["k1", "v2"]])
end
it "returns the list of cache entries for the default branch" do
runner.update(workflow_job: nil)
[
["k1", "v1", "dev"],
["k1", "v2", "main"],
["k1", "v1", "feature"],
["k2", "v1", "dev"]
].each do |key, version, branch|
GithubCacheEntry.create(key: key, version: version, scope: branch, repository_id: repository.id, created_by: runner.id, committed_at: Time.now)
end
get "/runtime/github/caches", {key: "k1"}
response = JSON.parse(last_response.body)
expect(response["totalCount"]).to eq(1)
expect(response["artifactCaches"].sort.map { [it["cacheKey"], it["cacheVersion"]] }).to eq([["k1", "v2"]])
end
end
end
end