Read about our upcoming Code of Conduct on this issue

update_pages_service.rb 5.41 KB
Newer Older
1
2
# frozen_string_literal: true

3
module Projects
4
  class UpdatePagesService < BaseService
5
    InvalidStateError = Class.new(StandardError)
6
    FailedToExtractError = Class.new(StandardError)
7

8
9
    BLOCK_SIZE = 32.kilobytes
    MAX_SIZE = 1.terabyte
10
    PUBLIC_DIR = 'public'
11
12
13

    # this has to be invalid group name,
    # as it shares the namespace with groups
14
    TMP_EXTRACT_PATH = '@pages.tmp'
15
16
17
18
19
20
21
22

    attr_reader :build

    def initialize(project, build)
      @project, @build = project, build
    end

    def execute
Shinya Maeda's avatar
Shinya Maeda committed
23
24
      register_attempt

25
26
      # Create status notifying the deployment of pages
      @status = create_status
27
      @status.enqueue!
28
29
      @status.run!

30
31
      raise InvalidStateError, 'missing pages artifacts' unless build.artifacts?
      raise InvalidStateError, 'pages are outdated' unless latest?
32
33

      # Create temporary directory in which we will extract the artifacts
34
      make_secure_tmp_dir(tmp_path) do |archive_path|
35
36
37
        extract_archive!(archive_path)

        # Check if we did extract public directory
38
        archive_public_path = File.join(archive_path, PUBLIC_DIR)
39
40
        raise InvalidStateError, 'pages miss the public folder' unless Dir.exist?(archive_public_path)
        raise InvalidStateError, 'pages are outdated' unless latest?
41
42
43
44

        deploy_page!(archive_public_path)
        success
      end
45
    rescue InvalidStateError => e
46
      error(e.message)
47
    rescue => e
48
      error(e.message)
49
      raise e
50
51
52
53
54
55
    end

    private

    def success
      @status.success
56
      @project.mark_pages_as_deployed
57
58
59
      super
    end

60
    def error(message)
61
      register_failure
62
      log_error("Projects::UpdatePagesService: #{message}")
63
64
      @status.allow_failure = !latest?
      @status.description = message
65
      @status.drop(:script_failure)
66
67
68
69
70
71
      super
    end

    def create_status
      GenericCommitStatus.new(
        project: project,
72
        pipeline: build.pipeline,
73
74
75
76
77
78
79
80
        user: build.user,
        ref: build.ref,
        stage: 'deploy',
        name: 'pages:deploy'
      )
    end

    def extract_archive!(temp_path)
81
      if artifacts.ends_with?('.zip')
82
83
        extract_zip_archive!(temp_path)
      else
84
        raise InvalidStateError, 'unsupported artifacts format'
85
86
87
88
      end
    end

    def extract_zip_archive!(temp_path)
89
      raise InvalidStateError, 'missing artifacts metadata' unless build.artifacts_metadata?
90
91

      # Calculate page size after extract
92
      public_entry = build.artifacts_metadata_entry(PUBLIC_DIR + '/', recursive: true)
93
94

      if public_entry.total_size > max_size
95
        raise InvalidStateError, "artifacts for pages are too large: #{public_entry.total_size}"
96
97
      end

98
      build.artifacts_file.use_file do |artifacts_path|
99
100
        SafeZip::Extract.new(artifacts_path)
          .extract(directories: [PUBLIC_DIR], to: temp_path)
101
      end
102
103
    rescue SafeZip::Extract::Error => e
      raise FailedToExtractError, e.message
104
105
    end

106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
    def deploy_page!(archive_public_path)
      # Do atomic move of pages
      # Move and removal may not be atomic, but they are significantly faster then extracting and removal
      # 1. We move deployed public to previous public path (file removal is slow)
      # 2. We move temporary public to be deployed public
      # 3. We remove previous public path
      FileUtils.mkdir_p(pages_path)
      begin
        FileUtils.move(public_path, previous_public_path)
      rescue
      end
      FileUtils.move(archive_public_path, public_path)
    ensure
      FileUtils.rm_r(previous_public_path, force: true)
    end

    def latest?
      # check if sha for the ref is still the most recent one
      # this helps in case when multiple deployments happens
      sha == latest_sha
    end

    def blocks
      # Calculate dd parameters: we limit the size of pages
130
131
132
133
      1 + max_size / BLOCK_SIZE
    end

    def max_size
134
      max_pages_size = Gitlab::CurrentSettings.max_pages_size.megabytes
135
136
137
138

      return MAX_SIZE if max_pages_size.zero?

      [max_pages_size, MAX_SIZE].min
139
140
141
    end

    def tmp_path
142
      @tmp_path ||= File.join(::Settings.pages.path, TMP_EXTRACT_PATH)
143
144
145
146
147
148
149
    end

    def pages_path
      @pages_path ||= project.pages_path
    end

    def public_path
150
      @public_path ||= File.join(pages_path, PUBLIC_DIR)
151
152
153
    end

    def previous_public_path
154
      @previous_public_path ||= File.join(pages_path, "#{PUBLIC_DIR}.#{SecureRandom.hex}")
155
156
157
158
159
160
    end

    def ref
      build.ref
    end

Micaël Bergeron's avatar
Micaël Bergeron committed
161
162
163
164
    def artifacts
      build.artifacts_file.path
    end

165
166
    def latest_sha
      project.commit(build.ref).try(:sha).to_s
167
168
169
    ensure
      # Close any file descriptors that were opened and free libgit2 buffers
      project.cleanup
170
171
172
173
174
    end

    def sha
      build.sha
    end
175
176

    def register_attempt
177
      pages_deployments_total_counter.increment
178
179
180
    end

    def register_failure
181
      pages_deployments_failed_total_counter.increment
182
183
184
185
186
187
188
189
190
    end

    def pages_deployments_total_counter
      @pages_deployments_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_total, "Counter of GitLab Pages deployments triggered")
    end

    def pages_deployments_failed_total_counter
      @pages_deployments_failed_total_counter ||= Gitlab::Metrics.counter(:pages_deployments_failed_total, "Counter of GitLab Pages deployments which failed")
    end
191
192
193
194
195
196
197
198
199
200

    def make_secure_tmp_dir(tmp_path)
      FileUtils.mkdir_p(tmp_path)
      path = Dir.mktmpdir(nil, tmp_path)
      begin
        yield(path)
      ensure
        FileUtils.remove_entry_secure(path)
      end
    end
201
202
  end
end