diff --git a/REFERENCE.md b/REFERENCE.md
index a81e2a6e..2039e8f3 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -6,6 +6,8 @@
### Classes
+#### Public Classes
+
* [`gitlab`](#gitlab): This module installs and configures Gitlab with the Omnibus package.
* [`gitlab::backup`](#gitlab--backup): This class is called from gitlab for backup config.
* [`gitlab::host_config`](#gitlab--host_config): This class is for setting host configurations required for gitlab installation.
@@ -14,6 +16,10 @@
* [`gitlab::omnibus_package_repository`](#gitlab--omnibus_package_repository): This class is used to configure gitlab repositories
* [`gitlab::service`](#gitlab--service): This class is meant to be called from gitlab. It ensure the service is running.
+#### Private Classes
+
+* `gitlab::initial_root_token`: Manages initial root token
+
### Defined types
* [`gitlab::custom_hook`](#gitlab--custom_hook): Manage custom hook files within a GitLab project.
@@ -129,6 +135,9 @@ The following parameters are available in the `gitlab` class:
* [`pgpass_file_location`](#-gitlab--pgpass_file_location)
* [`pgpass_file_ensure`](#-gitlab--pgpass_file_ensure)
* [`pgbouncer_password`](#-gitlab--pgbouncer_password)
+* [`create_initial_root_token`](#-gitlab--create_initial_root_token)
+* [`initial_root_token`](#-gitlab--initial_root_token)
+* [`initial_root_token_ttl_minutes`](#-gitlab--initial_root_token_ttl_minutes)
* [`consul`](#-gitlab--consul)
* [`custom_hooks_dir`](#-gitlab--custom_hooks_dir)
* [`system_hooks_dir`](#-gitlab--system_hooks_dir)
@@ -919,6 +928,30 @@ Password for the gitlab-consul database user in the pgbouncer database
Default value: `undef`
+##### `create_initial_root_token`
+
+Data type: `Boolean`
+
+Whether to create an initial root token. If set to true and initial_root_token is undef, a random token string will be generated.
+
+Default value: `false`
+
+##### `initial_root_token`
+
+Data type: `Optional[Sensitive[String[1]]]`
+
+Preset a root token to allow API usage immediately.
+
+Default value: `undef`
+
+##### `initial_root_token_ttl_minutes`
+
+Data type: `Integer[0]`
+
+Initial root token time to live (in minutes).
+
+Default value: `60`
+
##### `consul`
Data type: `Optional[Hash]`
diff --git a/files/gitlab_api_token_renewer.rb b/files/gitlab_api_token_renewer.rb
new file mode 100644
index 00000000..e3fbfd6a
--- /dev/null
+++ b/files/gitlab_api_token_renewer.rb
@@ -0,0 +1,105 @@
+#!/usr/bin/env ruby
+
+require 'net/http'
+require 'json'
+require 'time'
+require 'uri'
+require 'tempfile'
+
+class GitlabApiTokenRenewer
+ def initialize
+ @api_url = ENV.fetch('GITLAB_API_URL', 'http://localhost')
+ @token_file = ENV.fetch('GITLAB_API_TOKEN_FILE', '/var/opt/gitlab/.tokens/puppet_token')
+ @token_renew_days = ENV.fetch('GITLAB_API_TOKEN_RENEW_DAYS', '7').to_i
+ @new_token_ttl_days = ENV.fetch('GITLAB_API_NEW_TOKEN_TTL_DAYS', '30').to_i
+ @token = File.read(@token_file).strip
+
+ uri = URI(@api_url)
+ @http = Net::HTTP.new(uri.host, uri.port)
+ @http.use_ssl = uri.scheme == 'https'
+ end
+
+ def write_token
+ f = Tempfile.create('.tkn', File.dirname(@token_file))
+ f.write(token)
+ f.flush
+ f.close
+ File.rename(f, @token_file)
+ end
+
+ def api_request(method, endpoint, body = nil)
+ request_class = case method.downcase
+ when :get then Net::HTTP::Get
+ when :post then Net::HTTP::Post
+ else raise "Unsupported HTTP method"
+ end
+
+ request = request_class.new(uri)
+ request['Authorization'] = "Bearer #{token}"
+ request['Content-Type'] = 'application/json' if body
+ request.body = body.to_json if body
+
+ @http.request(request)
+ end
+
+ def get_current_token_info
+ response = api_request(:get, 'personal_access_tokens/self')
+
+ case response
+ when Net::HTTPSuccess
+ JSON.parse(response.body)
+ when Net::HTTPUnauthorized
+ abort "Token is invalid, revoked, or expired."
+ else
+ abort "Failed to get token info: #{response.code} #{response.body}"
+ end
+ end
+
+ def rotate_current_token(new_expiry = nil)
+ payload = {}
+ payload[:expires_at] = new_expiry if new_expiry
+
+ response = api_request(:post, 'personal_access_tokens/self/rotate', payload)
+
+ case response
+ when Net::HTTPSuccess
+ @token = JSON.parse(response.body)['token']
+ when Net::HTTPUnauthorized
+ abort "Token cannot be rotated (revoked, expired, or invalid)."
+ when Net::HTTPForbidden
+ abort "Token lacks permission to rotate (needs 'api' or 'self_rotate' scope)."
+ else
+ abort "Rotation failed: #{response.code} #{response.body}"
+ end
+ end
+
+ def run
+ info = get_current_token_info
+ expires_at_str = info['expires_at']
+
+ if expires_at_str.nil?
+ warn "Token has no expiration."
+ else
+ expires_at = Time.parse(expires_at_str).utc
+ threshold = Time.now.utc + (@token_renew_days * 86400)
+ if expires_at > threshold
+ puts "Token expires on #{expires_at}, still valid. No rotation needed."
+ exit 0
+ end
+ puts "Token expires on #{expires_at}, rotating..."
+ end
+
+ new_expiry = (Time.now + 30 * 24 * 60 * 60).strftime('%Y-%m-%d')
+ rotate_current_token(new_expiry)
+ puts "Token rotated in GitLab."
+
+ write_token
+ puts "New token written to #{@token_file}."
+
+ puts "Rotation complete."
+ end
+end
+
+if __FILE__ == $0
+ GitlabApiTokenRenewer.new.run
+end
diff --git a/manifests/init.pp b/manifests/init.pp
index 6d526268..47a3603e 100644
--- a/manifests/init.pp
+++ b/manifests/init.pp
@@ -117,7 +117,7 @@
# @param backup_cron_minute The minute when to run the daily backup cron job
# @param backup_cron_hour The hour when to run the daily backup cron job
# @param backup_cron_skips Array of items to skip valid values: db, uploads, repositories, builds, artifacts, lfs, registry, pages
-# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers.
+# @param package_hold Wether to hold the specified package version. Available options are 'hold' or 'none'. Defaults to 'none'. Available only for Debian/Solaris package managers.
# @param package_name The internal packaging system's name for the package. This name will automatically be changed by the gitlab::edition parameter. Can be overridden for the purposes of installing custom compiled version of gitlab-omnibus.
# @param manage_package Should the GitLab package be managed?
# @param repository_configuration A hash of repository types and attributes for configuraiton the gitlab package repositories. See docs in README.md
@@ -125,6 +125,9 @@
# @param pgpass_file_location Path to location of .pgpass file used by consul to authenticate with pgbouncer database
# @param pgpass_file_ensure Create .pgpass file for pgbouncer authentication. When set to present requires valid value for pgbouncer_password.
# @param pgbouncer_password Password for the gitlab-consul database user in the pgbouncer database
+# @param manage_api_token Whether to manage the API token. This token belongs to the root Gitlab user.
+# @param api_token_dir Where to store the API token generated.
+# @param api_token_ttl_days API token time to live in days.
class gitlab (
Hash $repository_configuration,
# package configuration
@@ -224,6 +227,9 @@
Optional[Hash] $gitlab_workhorse = undef,
Optional[Hash] $user = undef,
Optional[Hash] $web_server = undef,
+ Boolean $manage_api_token = false,
+ Stdlib::Absolutepath $api_token_file = '/var/opt/gitlab/.tokens/puppet_token',
+ Integer[0] $api_token_ttl_days = 30,
Boolean $backup_cron_enable = false,
Integer[0,59] $backup_cron_minute = 0,
Integer[0,23] $backup_cron_hour = 2,
@@ -238,11 +244,13 @@
contain gitlab::omnibus_config
contain gitlab::install
contain gitlab::service
+ contain gitlab::initial_root_token
Class['gitlab::host_config']
-> Class['gitlab::omnibus_config']
-> Class['gitlab::install']
-> Class['gitlab::service']
+ -> Class['gitlab::initial_root_token']
$custom_hooks.each |$name, $options| {
gitlab::custom_hook { $name:
diff --git a/manifests/initial_root_token.pp b/manifests/initial_root_token.pp
new file mode 100644
index 00000000..79992768
--- /dev/null
+++ b/manifests/initial_root_token.pp
@@ -0,0 +1,49 @@
+# @summary Manages initial root token
+#
+# **NOTE** This hack allows to use the gitlab instance via API immediately.
+# While this way is quite convenient, it cannot be called a good one..
+# Use it at your own risk!
+#
+# Remove the /etc/gitlab/initial_root_token file to regenerate the token in a
+# next Puppet run.
+#
+# @see https://docs.gitlab.com/administration/operations/rails_console/#using-the-rails-runner
+# @see https://docs.gitlab.com/user/profile/personal_access_tokens/#create-a-personal-access-token-programmatically
+#
+# @api private
+class gitlab::initial_root_token {
+ $api_token_file = $gitlab::api_token_file
+ $script_path = '/etc/gitlab/create_initial_root_token.rb'
+
+ if $gitlab::create_initial_root_token {
+ $script_ensure = 'file'
+ $script_content = epp('gitlab/create_initial_root_token.rb.epp',
+ token => $gitlab::initial_root_token,
+ token_ttl_minutes => $gitlab::initial_root_token_ttl_minutes,
+ token_file_path => $token_file_path,
+ )
+
+ # Execute after the script is created, but only if token is managed
+ exec { 'create_initial_root_token':
+ command => "/usr/bin/gitlab-rails runner '${script_path}'",
+ creates => $token_file_path,
+ require => File[$script_path],
+ }
+ } else {
+ $script_ensure = 'absent'
+ $script_content = undef
+
+ # Ensure there is no token file left if it was created before
+ file { $token_file_path:
+ ensure => 'absent',
+ }
+ }
+
+ file { $script_path:
+ ensure => $script_ensure,
+ owner => 'root',
+ group => 'git', # gitlab-rails runner executes this script as 'git' user
+ mode => '0640',
+ content => $script_content,
+ }
+}
diff --git a/spec/classes/init_spec.rb b/spec/classes/init_spec.rb
index e5637c4e..bef2d9bd 100644
--- a/spec/classes/init_spec.rb
+++ b/spec/classes/init_spec.rb
@@ -11,7 +11,11 @@
it { is_expected.to contain_class('gitlab::host_config').that_comes_before('Class[gitlab::install]') }
it { is_expected.to contain_class('gitlab::omnibus_config').that_comes_before('Class[gitlab::install]') }
it { is_expected.to contain_class('gitlab::install').that_comes_before('Class[gitlab::service]') }
- it { is_expected.to contain_class('gitlab::service') }
+ it { is_expected.to contain_class('gitlab::service').that_comes_before('Class[gitlab::initial_root_token]') }
+ it { is_expected.to contain_class('gitlab::initial_root_token') }
+ it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_ensure('absent') }
+ it { is_expected.to contain_file('/etc/gitlab/initial_root_token').with_ensure('absent') }
+ it { is_expected.not_to contain_exec('create_initial_root_token') }
it { is_expected.to contain_exec('gitlab_reconfigure').that_subscribes_to('Class[gitlab::omnibus_config]') }
it { is_expected.to contain_file('/etc/gitlab/gitlab.rb') }
it { is_expected.to contain_package('gitlab-omnibus').with_ensure('installed').with_name('gitlab-ce') }
@@ -511,6 +515,30 @@
is_expected.to contain_package('gitlab-omnibus').with('ensure' => '16.10.3-ce.0', 'name' => 'gitlab-ce', 'mark' => 'hold')
}
end
+ describe 'create_intial_root_token' do
+ let(:params) { { create_initial_root_token: true } }
+
+ it do
+ is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').
+ with_content(%r{^token = nil$}).
+ with_content(%r{^token_ttl_minutes = 60$}).
+ with_content(%r{^token_file_path = '/etc/gitlab/initial_root_token'$})
+ end
+ it { is_expected.to contain_exec('create_initial_root_token').with_creates('/etc/gitlab/initial_root_token') }
+ it { is_expected.not_to contain_file('/etc/gitlab/initial_root_token') } # This file is managed only if create_initial_root_token is false
+
+ describe 'initial_root_token' do
+ let(:params) { super().merge(initial_root_token: sensitive('foobarbaz')) }
+
+ it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token = 'foobarbaz'$}) }
+ end
+
+ describe 'initial_root_token_ttl_minutes' do
+ let(:params) { super().merge(initial_root_token_ttl_minutes: 123) }
+
+ it { is_expected.to contain_file('/etc/gitlab/create_initial_root_token.rb').with_content(%r{^token_ttl_minutes = 123$}) }
+ end
+ end
end
end
end
diff --git a/templates/create_initial_root_token.rb.epp b/templates/create_initial_root_token.rb.epp
new file mode 100644
index 00000000..f0c62217
--- /dev/null
+++ b/templates/create_initial_root_token.rb.epp
@@ -0,0 +1,24 @@
+<%-|
+ Optional[Sensitive[String[1]]] $token,
+ Integer[0] $token_ttl_minutes,
+ Stdlib::AbsolutePath $token_file_path,
+|-%>
+# This script should be executed with 'gitlab-rails runner' command.
+#
+# This scripts creates an initial root token and stores it to the
+# <%= $token_file_path %> file.
+token = <%= $token.then |$x| { "'${$x.unwrap}'" }.lest || { nil } %>
+token_ttl_minutes = <%= $token_ttl_minutes %>
+token_file_path = '<%= $token_file_path %>'
+
+require 'securerandom'
+token_value = token || 'glpat-' + SecureRandom.alphanumeric(20)
+
+t = User.find(1).personal_access_tokens.create(
+ scopes: [:api],
+ name: 'Gitlab Puppet module initial root token',
+ expires_at: token_ttl_minutes.minutes.from_now,
+)
+t.set_token(token_value)
+t.save!
+File.write(token_file_path, token_value, perm: 0600)