snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
#
# Backport safety check (Postgres only).
#
# A release branch is an older subset of master. When a PR backports a migration
# it must keep the exact version number and name that migration already has on
# master; renumbering or renaming a shipped migration breaks the upgrade path
# (the MM-68848 class of bug).
#
# We therefore only look at the migrations this PR ADDS on top of its base branch
# and require each of them to already exist on master. Files that are already on
# the base branch are left untouched, so historical states such as the Postgres
# migrations renamed before the pre-migration infra landed do not produce false
# positives.
#
# MySQL is intentionally skipped: support was dropped after v11.0, so older
# release branches still carry MySQL migrations that no longer exist on master.
#
# Portable across the bash 3.2 that ships with macOS and Linux CI: uses only
# POSIX-ish tools plus process substitution, no bash 4 features.
#
set -euo pipefail
export LC_ALL=C
# Branch the PR targets. The migrations the PR adds are everything HEAD has that
# this ref does not.
base_ref="${MM_MIGRATION_CHECK_BASE_REF:-origin/master}"
# Canonical source of shipped migrations the additions must match.
canonical_ref="${MM_MIGRATION_CHECK_CANONICAL_REF:-origin/master}"
repo_root="$(git rev-parse --show-toplevel)"
# Postgres only; MySQL migrations linger on old release branches and are skipped.
migrations_dir="server/channels/db/migrations/postgres"
for ref in "$base_ref" "$canonical_ref"; do
if ! git -C "$repo_root" rev-parse --verify --quiet "${ref}^{commit}" >/dev/null; then
echo "Ref '$ref' not found. Fetch it first (e.g. 'git fetch origin master')" >&2
echo "or set MM_MIGRATION_CHECK_BASE_REF / MM_MIGRATION_CHECK_CANONICAL_REF." >&2
exit 2
fi
done
# path -> "version_name.kind"; the up/down kind is part of the identity so a
# one-sided rename can't be masked by its surviving partner file.
norm='s#^'"$migrations_dir"'/([0-9]+_.+)\.(up|down)\.sql$#\1.\2#p'
ref_files() {
git -C "$repo_root" ls-tree -r --name-only "$1" -- "$migrations_dir" \
| sed -nE "$norm" | sort -u
}
head_files() {
# Tracked + untracked files actually present on disk, so a plain `mv`
# without `git add` is still caught.
git -C "$repo_root" ls-files --cached --others --exclude-standard -- "$migrations_dir" \
| while IFS= read -r path; do
[ -f "$repo_root/$path" ] && printf '%s\n' "$path"
done \
| sed -nE "$norm" | sort -u
}
# Migrations this PR adds on top of its base branch.
added="$(comm -23 <(head_files) <(ref_files "$base_ref"))"
# Of those, the ones that don't exist on the canonical branch are renames,
# renumbers, or otherwise stray.
stray="$(comm -23 <(printf '%s\n' "$added" | sed '/^$/d') <(ref_files "$canonical_ref"))"
if [ -n "$stray" ]; then
count="$(printf '%s\n' "$stray" | wc -l | tr -d '[:space:]')"
echo "Found $count migration file(s) added by this branch that do not exist on $canonical_ref:" >&2
printf '%s\n' "$stray" | while IFS= read -r m; do
echo " - ${m}.sql is added on this branch but not present on $canonical_ref; renaming or renumbering a shipped migration breaks upgrades. Add a new migration instead." >&2
done
exit 1
fi
echo "All migrations added by this branch exist on $canonical_ref."

View File

@@ -0,0 +1,46 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"encoding/json"
"fmt"
"os"
"github.com/mattermost/mattermost/server/public/model"
)
// generateDefaultConfig writes default config to outputFile.
func generateDefaultConfig(outputFile *os.File) error {
defaultCfg := &model.Config{}
defaultCfg.SetDefaults()
if data, err := json.MarshalIndent(defaultCfg, "", " "); err != nil {
return err
} else if _, err := outputFile.Write(data); err != nil {
return err
}
return nil
}
func main() {
outputFile := os.Getenv("OUTPUT_CONFIG")
if outputFile == "" {
fmt.Println("Output file name is missing. Please set OUTPUT_CONFIG env variable to absolute path")
os.Exit(2)
}
if _, err := os.Stat(outputFile); !os.IsNotExist(err) {
_, _ = fmt.Fprintf(os.Stderr, "File %s already exists. Not overwriting!\n", outputFile)
os.Exit(2)
}
if file, err := os.Create(outputFile); err == nil {
err = generateDefaultConfig(file)
_ = file.Close()
if err != nil {
panic(err)
}
} else {
panic(err)
}
}

View File

@@ -0,0 +1,38 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"encoding/json"
"os"
"testing"
"github.com/mattermost/mattermost/server/public/model"
"github.com/stretchr/testify/require"
)
func TestDefaultsGenerator(t *testing.T) {
tmpFile, err := os.CreateTemp("", "tempconfig")
defer os.Remove(tmpFile.Name())
require.NoError(t, err)
require.NoError(t, generateDefaultConfig(tmpFile))
_ = tmpFile.Close()
var config model.Config
b, err := os.ReadFile(tmpFile.Name())
require.NoError(t, err)
require.NoError(t, json.Unmarshal(b, &config))
require.Equal(t, *config.FileSettings.PublicLinkSalt, "")
require.Equal(t, *config.Office365Settings.Scope, model.Office365SettingsDefaultScope)
require.Equal(t, *config.Office365Settings.AuthEndpoint, model.Office365SettingsDefaultAuthEndpoint)
require.Equal(t, *config.Office365Settings.UserAPIEndpoint, model.Office365SettingsDefaultUserAPIEndpoint)
require.Equal(t, *config.Office365Settings.TokenEndpoint, model.Office365SettingsDefaultTokenEndpoint)
require.Equal(t, *config.GoogleSettings.Scope, model.GoogleSettingsDefaultScope)
require.Equal(t, *config.GoogleSettings.AuthEndpoint, model.GoogleSettingsDefaultAuthEndpoint)
require.Equal(t, *config.GoogleSettings.UserAPIEndpoint, model.GoogleSettingsDefaultUserAPIEndpoint)
require.Equal(t, *config.GoogleSettings.TokenEndpoint, model.GoogleSettingsDefaultTokenEndpoint)
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package main
import (
"flag"
"fmt"
"os"
"slices"
"strings"
"text/template"
"github.com/mattermost/mattermost/server/public/model"
"github.com/mattermost/mattermost/server/v8/channels/app"
"github.com/mattermost/mattermost/server/v8/channels/store/storetest"
"github.com/mattermost/mattermost/server/v8/channels/utils"
"github.com/mattermost/mattermost/server/v8/config"
)
type roleEntry struct {
Name string
Permissions string
}
var tmpl = template.Must(template.New("").Parse(`// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Code generated by generate_default_roles_permissions.go. DO NOT EDIT.
export const defaultRolesPermissions = {
{{- range .}}
{{.Name}}: '{{.Permissions}}',
{{- end}}
};
`))
func main() {
outPath := flag.String("out", "", "output file path")
flag.Parse()
if *outPath == "" {
fmt.Fprintln(os.Stderr, "usage: generate_default_roles_permissions -out <path>")
os.Exit(1)
}
if err := utils.TranslationsPreInit(); err != nil {
fmt.Fprintf(os.Stderr, "warning: could not load translations: %v\n", err)
}
sqlSettings := storetest.MakeSqlSettings(model.DatabaseDriverPostgres)
defer storetest.CleanupSqlSettings(sqlSettings)
cfg := model.Config{}
cfg.SetDefaults()
cfg.SqlSettings = *sqlSettings
memStore, err := config.NewMemoryStoreWithOptions(&config.MemoryStoreOptions{
InitialConfig: &cfg,
})
if err != nil {
fmt.Fprintf(os.Stderr, "error creating config store: %v\n", err)
os.Exit(1)
}
cfgStore, err := config.NewStoreFromBacking(memStore, nil, false)
if err != nil {
fmt.Fprintf(os.Stderr, "error creating config store: %v\n", err)
os.Exit(1)
}
// NewServer runs both schema migrations (via sqlstore.New) and all
// app-level migrations (doAppMigrations) as part of its initialization.
server, err := app.NewServer(app.ConfigStore(cfgStore))
if err != nil {
fmt.Fprintf(os.Stderr, "error creating server: %v\n", err)
os.Exit(1)
}
defer server.Shutdown()
roles, err := server.Store().Role().GetAll()
if err != nil {
fmt.Fprintf(os.Stderr, "error reading roles: %v\n", err)
os.Exit(1)
}
defaultNames := make(map[string]bool, len(model.MakeDefaultRoles()))
for name := range model.MakeDefaultRoles() {
defaultNames[name] = true
}
var roleNames []string
roleByName := make(map[string]*model.Role, len(roles))
for _, r := range roles {
if defaultNames[r.Name] {
roleNames = append(roleNames, r.Name)
roleByName[r.Name] = r
}
}
slices.Sort(roleNames)
entries := make([]roleEntry, 0, len(roleNames))
for _, name := range roleNames {
perms := make([]string, len(roleByName[name].Permissions))
copy(perms, roleByName[name].Permissions)
slices.Sort(perms)
perms = slices.Compact(perms)
entries = append(entries, roleEntry{
Name: name,
Permissions: strings.Join(perms, " "),
})
}
var b strings.Builder
if err := tmpl.Execute(&b, entries); err != nil {
fmt.Fprintf(os.Stderr, "error executing template: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(*outPath, []byte(b.String()), 0644); err != nil {
fmt.Fprintf(os.Stderr, "error writing %s: %v\n", *outPath, err)
os.Exit(1)
}
fmt.Printf("Generated %s\n", *outPath)
}

47
server/scripts/diff-config.sh Executable file
View File

@@ -0,0 +1,47 @@
#!/usr/bin/env bash
./jq-dep-check.sh
if [ -z "$FROM" ]
then
echo "Missing FROM version. Usage: make diff-config FROM=1.1.1 TO=2.2.2"
exit 1
fi
if [ -z "$TO" ]
then
echo "Missing TO version. Usage: make diff-config FROM=1.1.1 TO=2.2.2"
exit 1
fi
# Returns the config file for a specific release
fetch_config() {
local url="https://releases.mattermost.com/$1/mattermost-$1-linux-amd64.tar.gz"
curl -sf "$url" | tar -xzOf - mattermost/config/config.json | jq -S .
}
echo Fetching config files
from_config="$(fetch_config "$FROM")"
if [ -z "$from_config" ]
then
echo Invalid version "$FROM"
exit 1
fi
to_config=$(fetch_config "$TO")
if [ -z "$to_config" ]
then
echo Invalid version "$TO"
exit 1
fi
echo Comparing config files
diff -y <(echo "$from_config") <(echo "$to_config")
# We ignore exits with 1 since it just means there's a difference, which is fine for us.
diff_exit=$?
if [ $diff_exit -eq 1 ]; then
exit 0
else
exit $diff_exit
fi

View File

@@ -0,0 +1,11 @@
#!/usr/bin/env bash
make build-templates
if [[ `git status templates/ --porcelain` ]]; then
echo "mjml templates have changed; Please compile and include compiled files"
git diff templates/ # show diffs as part of error message
exit 1
else
echo "PASS"
fi

View File

@@ -0,0 +1 @@
A collection of ad-hoc scripts to upgrade between ESRs.

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# $1 - repo to check against
# $2 - release branch pattern to match
# This script will check if the current repo has version pattern $2 ('release-5.20') in the branch name.
# If it does it will check for a matching release version in the $1 repo and if found check for the newest dot version.
# If the branch pattern isn't found than it will default to the newest release available in $1.
REPO_TO_USE=$1
BRANCH_TO_USE=$2
BASIC_AUTH=""
# If we find a github username and token, we use that.
# In CI, these variables are available and useful to avoid rate limits which is
# much more strict for unauthenticated requests.
if [[ ! -z "$GITHUB_USERNAME" && ! -z "$GITHUB_TOKEN" ]];
then
BASIC_AUTH="--user $GITHUB_USERNAME:$GITHUB_TOKEN"
fi
LATEST_RELEASE=$(curl \
--silent \
$BASIC_AUTH \
"https://api.github.com/repos/$REPO_TO_USE/releases/latest")
RELEASES=$(curl \
--silent \
$BASIC_AUTH \
"https://api.github.com/repos/$REPO_TO_USE/releases")
LATEST_REL=$(echo "$LATEST_RELEASE" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
DRAFT=$(echo "$LATEST_RELEASE" | grep '"draft":' | sed -E 's/.*: ([^,]+).*/\1/')
PRERELEASE=$(echo "$RELEASES" | grep '"prerelease":' | sed -E 's/.*: ([^,]+).*/\1/')
# Check if this is a release branch
THIS_BRANCH=$(git rev-parse --abbrev-ref HEAD)
#THIS_BRANCH="release-5.27"# - Used to test release logic on a non release branch
if [[ "$THIS_BRANCH" =~ $BRANCH_TO_USE || $DRAFT =~ "true" ]]; then
VERSION_REL=${THIS_BRANCH//$BRANCH_TO_USE/v}
REL_TO_USE=$(echo "$RELEASES" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sed -n "/$VERSION_REL/p" | sort -rV | head -n 1)
elif [[ "$THIS_BRANCH" =~ "master" ]]; then
# Get the latest release even if its a pre-release
REL_TO_USE=$(echo "$RELEASES" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/' | sort -rV | head -n 1)
else
REL_TO_USE=$LATEST_REL
fi
if [[ -z "$REL_TO_USE" ]]
then
echo "An error has occurred trying to get the latest mmctl release. Aborting. Perhaps api.github.com is down, or you are being rate-limited.";
echo "Set the GITHUB_USERNAME and GITHUB_TOKEN environment variables to the appropriate values to work around GitHub rate-limiting.";
exit 1;
else
echo "$REL_TO_USE"
fi

9
server/scripts/jq-dep-check.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
jq_cmd=jq
[[ $(type -P "$jq_cmd") ]] || {
echo "'$jq_cmd' command line JSON processor not found";
echo "Please install on linux with 'sudo apt-get install jq'"
echo "Please install on mac with 'brew install jq'"
exit 1;
}

97
server/scripts/ldap-check.sh Executable file
View File

@@ -0,0 +1,97 @@
#!/bin/bash
jq_cmd=jq
[[ $(type -P "$jq_cmd") ]] || {
echo "'$jq_cmd' command line JSON processor not found";
echo "Please install on linux with 'sudo apt-get install jq'"
echo "Please install on mac with 'brew install jq'"
exit 1;
}
ldapsearch_cmd=ldapsearch
[[ $(type -P "$ldapsearch_cmd") ]] || {
echo "'$ldapsearch_cmd' shell accessible interface to ldap not found";
echo "Please install on linux with 'sudo apt-get install ldap-utils'"
exit 1;
}
if [[ -z ${1} ]]; then
echo "We could not find a username";
echo "usage: ./ldap-check.sh -u/-g [username/groupname]"
echo "example: ./ldap-check.sh -u john"
echo "example: ./ldap-check.sh -g admin-staff"
exit 1;
fi
find_config_file() {
local config_paths=("./config.json" "./config/config.json" "../config/config.json" "/opt/mattermost/config/config.json")
for path in "${config_paths[@]}"; do
if [[ -e "$path" ]]; then
echo "$path"
return 0
fi
done
return 1
}
config_file=$(find_config_file)
if [[ $? -ne 0 ]]; then
echo "We could not find config.json"
exit 1
fi
LdapServer=`cat $config_file | jq -r .LdapSettings.LdapServer`
LdapPort=`cat $config_file | jq -r .LdapSettings.LdapPort`
ConnectionSecurity=`cat $config_file | jq -r .LdapSettings.ConnectionSecurity`
BindUsername=`cat $config_file | jq -r .LdapSettings.BindUsername`
BindPassword=`cat $config_file | jq -r .LdapSettings.BindPassword`
BaseDN=`cat $config_file | jq -r .LdapSettings.BaseDN`
UserFilter=`cat $config_file | jq -r .LdapSettings.UserFilter`
EmailAttribute=`cat $config_file | jq -r .LdapSettings.EmailAttribute`
UsernameAttribute=`cat $config_file | jq -r .LdapSettings.UsernameAttribute`
IdAttribute=`cat $config_file | jq -r .LdapSettings.IdAttribute`
GroupFilter=`cat $config_file | jq -r .LdapSettings.GroupFilter`
GroupIdAttribute=`cat $config_file | jq -r .LdapSettings.GroupIdAttribute`
if [[ -z ${ConnectionSecurity} || ${ConnectionSecurity} == "null" ]]; then
LdapUri="ldap://$LdapServer:$LdapPort"
StartTlsFlag=""
elif [[ ${ConnectionSecurity} == "STARTTLS" ]]; then
LdapUri="ldap://$LdapServer:$LdapPort"
StartTlsFlag="-ZZ"
else
LdapUri="ldaps://$LdapServer:$LdapPort"
StartTlsFlag=""
fi
if [[ -z ${UserFilter} ]]; then
UserFilter="($IdAttribute=$2)"
else
UserFilter="(&($IdAttribute=$2)$UserFilter)"
fi
if [[ -z ${GroupFilter} ]]; then
GroupFilter="($GroupIdAttribute=$2)"
else
GroupFilter="(&($GroupIdAttribute=$2)$GroupFilter)"
fi
run_ldap_search() {
local filter="$1"
local attributes="$2"
cmd_to_run="$ldapsearch_cmd -LLL -x $StartTlsFlag -H \"$LdapUri\" -D \"$BindUsername\" -w \"$BindPassword\" -b \"$BaseDN\" \"$filter\" $attributes"
echo $cmd_to_run
echo "-------------------------"
eval $cmd_to_run
}
if [[ $1 == '-u' ]]; then
run_ldap_search "$UserFilter" "$IdAttribute $UsernameAttribute $EmailAttribute"
elif [[ $1 == '-g' ]]; then
run_ldap_search "$GroupFilter" ""
else
echo "User or Group not specified"
fi

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"postgres": {
"10": "postgres:10@sha256:7a484b11fcabd39596b1bf08780cdb61fa9b0d8bfad0844dbdce3a6922df95d1",
"12": "postgres:12@sha256:cc7a021d9aff3aa02788d35c27a5cc32d4790ad92d72232a6be75b76ab7d79db",
"13": "postgres:13@sha256:1b154a7bbf474aa1a2e67dc7c976835645fe6c3425320e7ad3f5a926d509e8fc",
"14": "postgres:14@sha256:1c418702ab77adc7e84c7e726c2ab4f9cb63b8f997341ffcfab56629bab1429d",
"15": "postgres:15@sha256:6ab12ad4395ee49ab49fe19530f7e183c5a9c97fc47cf687b3e281bec5f91ee4",
"16": "postgres:16@sha256:9c1534cbf839ec70409508a874f4c02bf4739de2f32f77efe080eef1cfd34bf4",
"17": "postgres:17@sha256:4f6c1ec9743885ba6438a91f7c08c4ef4d645a41eae31fc8521c2d831c622107"
},
"minio": {
"RELEASE.2026-07-17T12-07-51Z": "cgr.dev/chainguard/minio@sha256:039800e64ec7247d2fde7cff3697e964f6fe20b6d7d2c46aa7d82cc63355d512"
},
"redis": {
"7.4.0": "redis:7.4.0@sha256:6725a7dc7a44a6486b9d0a5172b10ccaf0c2ea600df87c0b93450d0e7769297f"
},
"inbucket": {
"3.0.0": "inbucket/inbucket:3.0.0@sha256:1f10a0efea694592c06799c729aee1d6d71c9a4f72b73031d4a426ef5f26dfc1"
},
"openldap": {
"1.4.0": "osixia/openldap:1.4.0@sha256:d5b2f2b816b25a1b57033b34f5d48c91cc3161a7d041811a9032604030bad9db"
},
"keycloak": {
"10.0.2": "quay.io/keycloak/keycloak:10.0.2@sha256:fa434fd4e96f03242295febb37fb648b9ee271315ba7380d06003b43a42b5195",
"23.0.7": "quay.io/keycloak/keycloak:23.0.7@sha256:14e99d6f5dd0516a5bdc82537b732cb85469ecdb15ad7fe5f11ff67521544db8"
},
"dejavu": {
"3.4.2": "appbaseio/dejavu:3.4.2@sha256:8f2f4d45565da53c4235495737fff3921d302955daeb2f53a433c7b0e2044951"
},
"prometheus": {
"v2.27.1": "prom/prometheus:v2.27.1@sha256:5accb68b56ba452e449a5e552411acaeabbbe0f087acf19a1157ce3dd10a8bed"
},
"grafana": {
"8.0.1": "grafana/grafana:8.0.1@sha256:1c3e2fc7896adf9e33be5d062c08066087cb556f63b0a95f8aefe92bd37a6f38"
},
"cypress-browsers-public": {
"node16.14.2-slim-chrome100-ff99-edge": "cypress/browsers:node16.14.2-slim-chrome100-ff99-edge@sha256:f8459ee677ce356eff64698095fbdf48eb9ab018fc5eb0d30c07ba23884edace",
"node-18.16.1-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1": "cypress/browsers:node-18.16.1-chrome-114.0.5735.133-1-ff-114.0.2-edge-114.0.1823.51-1@sha256:f798d6cc25d724210be3660e95f63ae75d052ef392e3fc5ae728d02da7e52df8"
},
"node": {
"16.10.0": "node:16.10.0",
"16.17.0": "node:16.17.0",
"18": "node:18@sha256:11e9c297fc51f6f65f7d0c7c8a8581e5721f2f16de43ceff1a199fd3ef609f95",
"18.17": "node:18.17@sha256:11e9c297fc51f6f65f7d0c7c8a8581e5721f2f16de43ceff1a199fd3ef609f95",
"18.17.1": "node:18.17.1@sha256:11e9c297fc51f6f65f7d0c7c8a8581e5721f2f16de43ceff1a199fd3ef609f95",
"20.11": "node:20.11@sha256:e06aae17c40c7a6b5296ca6f942a02e6737ae61bbbf3e2158624bb0f887991b5"
}
}

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -eu -o pipefail
# Assert that IMAGE_FILE var is given, and that the file exists
: ${IMAGES_FILE}
[ -f ${IMAGES_FILE} ] || {
echo "Error: images spec file $IMAGES_FILE does not exist. Aborting." >&2
exit 1
}
DRY_RUN=${DRY_RUN:-yes}
log () { echo "[$(date -Is)]" $*; }
get_image_specs_per_line () {
jq -c '. as $images | keys[] | . as $image | $images[.] | keys[] | {dst_img_name: $image, dst_img_tag: ., src_img: $images[$image][.]}' <$IMAGES_FILE
}
log "Pusing images from given spec file: $IMAGES_FILE"
log "Content of the spec file:"
cat $IMAGES_FILE
get_image_specs_per_line | while read IMAGE_SPEC; do
DST_IMG_NAME=$(jq -r '.dst_img_name' <<<$IMAGE_SPEC)
DST_IMG_TAG=$(jq -r '.dst_img_tag' <<<$IMAGE_SPEC)
SOURCE_IMAGE=$(jq -r '.src_img' <<<$IMAGE_SPEC)
DESTINATION_IMAGE=mattermostdevelopment/mirrored-${DST_IMG_NAME}:${DST_IMG_TAG}
if [ "${DRY_RUN,,}" = "no" ]; then
log "Pushing image: $SOURCE_IMAGE ---> $DESTINATION_IMAGE"
docker pull $SOURCE_IMAGE
docker tag $SOURCE_IMAGE $DESTINATION_IMAGE
docker push $DESTINATION_IMAGE
else
log "Pushing image: $SOURCE_IMAGE ---> $DESTINATION_IMAGE (dry run mode, set the DRY_RUN=no env var to disable)"
fi
done
log "All images pushed."

View File

@@ -0,0 +1,11 @@
#!/bin/bash
check_prereq()
{
local dependency=$1
type "$dependency" >/dev/null 2>&1 || { echo >&2 "Mattermost Enterprise requires '$dependency' but it doesn't appear to be installed. Aborting."; exit 1; }
}
echo "Checking enterprise prerequisites"
check_prereq 'xmlsec1'

View File

@@ -0,0 +1,59 @@
./scripts/jq-dep-check.sh
TMPDIR=`mktemp -d 2>/dev/null || mktemp -d -t 'tmpConfigDir'`
DUMPDIR=`mktemp -d 2>/dev/null || mktemp -d -t 'dumpDir'`
SCHEMA_VERSION=$1
echo "Creating databases"
docker exec mattermost-postgres sh -c 'exec echo "CREATE DATABASE migrated; CREATE DATABASE latest;" | exec psql -U mmuser mattermost_test'
echo "Importing postgres dump from version ${SCHEMA_VERSION}"
docker exec -i mattermost-postgres psql -U mmuser -d migrated < $(pwd)/scripts/mattermost-postgresql-$SCHEMA_VERSION.sql
docker exec -i mattermost-postgres psql -U mmuser -d migrated -c "INSERT INTO Systems (Name, Value) VALUES ('Version', '$SCHEMA_VERSION')"
echo "Setting up config for db migration"
cat config/config.json | \
jq '.SqlSettings.DataSource = "postgres://mmuser:mostest_password@localhost:5432/migrated?sslmode=disable&connect_timeout=10"'| \
jq '.SqlSettings.DriverName = "postgres"' > $TMPDIR/config.json
echo "Running the migration"
make ARGS="db migrate --config $TMPDIR/config.json" run-cli
echo "Setting up config for fresh db setup"
cat config/config.json | \
jq '.SqlSettings.DataSource = "postgres://mmuser:mostest_password@localhost:5432/latest?sslmode=disable&connect_timeout=10"'| \
jq '.SqlSettings.DriverName = "postgres"' > $TMPDIR/config.json
echo "Setting up fresh db"
make ARGS="db migrate --config $TMPDIR/config.json" run-cli
if [ "$SCHEMA_VERSION" == "5.0.0" ]; then
for i in "ChannelMembers MentionCountRoot" "ChannelMembers MsgCountRoot" "Channels TotalMsgCountRoot"; do
a=( $i );
echo "Ignoring known Postgres mismatch: ${a[0]}.${a[1]}"
docker exec mattermost-postgres psql -U mmuser -d migrated -c "ALTER TABLE ${a[0]} DROP COLUMN ${a[1]};"
docker exec mattermost-postgres psql -U mmuser -d latest -c "ALTER TABLE ${a[0]} DROP COLUMN ${a[1]};"
done
fi
echo "Generating dump"
docker exec mattermost-postgres pg_dump --schema-only -d migrated -U mmuser > $DUMPDIR/migrated.sql
docker exec mattermost-postgres pg_dump --schema-only -d latest -U mmuser > $DUMPDIR/latest.sql
echo "Removing databases created for db comparison"
docker exec mattermost-postgres sh -c 'exec echo "DROP DATABASE migrated; DROP DATABASE latest;" | exec psql -U mmuser mattermost_test'
echo "Generating diff"
git diff --word-diff=color $DUMPDIR/migrated.sql $DUMPDIR/latest.sql > $DUMPDIR/diff.txt
diffErrorCode=$?
if [ $diffErrorCode -eq 0 ]; then
echo "Both schemas are same"
else
echo "Schema mismatch"
cat $DUMPDIR/diff.txt
fi
rm -rf $TMPDIR $DUMPDIR
exit $diffErrorCode

130
server/scripts/run-shard-tests.sh Executable file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
set -uo pipefail
# run-shard-tests.sh — Multi-run test wrapper for sharded CI
#
# When a shard has both "light" packages (run whole) and "heavy" package
# splits (run with -run regex), we need multiple gotestsum invocations.
# The Makefile's test-server target only supports a single invocation,
# so this script calls gotestsum directly.
#
# Each invocation produces its own JUnit XML and JSON log files, which
# are merged at the end into the standard report.xml and gotestsum.json
# that the CI pipeline expects.
#
# Input files (in working directory, written by shard-split.js):
# shard-te-packages.txt — space-separated TE packages
# shard-ee-packages.txt — space-separated EE packages
# shard-heavy-runs.txt — one line per heavy run: "pkg REGEX"
#
# Environment variables (set by CI):
# RACE_MODE — "-race" on master, empty on PRs
# ENABLE_COVERAGE — "true" to enable coverage profiling
GOBIN="$(pwd)/bin"
# Set up build prerequisites (go.work, gotestsum, go versions)
# These are normally done by make test-server-pre.
make setup-go-work gotestsum golang-versions
GOFLAGS_BASE="-buildvcs=false -timeout=90m"
RACE_FLAG="${RACE_MODE:-}"
RUN_IDX=0
FAILURES=0
# run_gotestsum PACKAGES [RUN_REGEX]
# $1 = space-separated package list
# $2 = optional -run regex (passed directly to go test)
run_gotestsum() {
local junitfile="report-${RUN_IDX}.xml"
local jsonfile="gotestsum-${RUN_IDX}.json"
local run_flag=""
if [[ -n "${2:-}" ]]; then run_flag="-run $2"; fi
local coverage_flag=""
if [[ "${ENABLE_COVERAGE:-false}" == "true" ]]; then
coverage_flag="-coverprofile=cover-${RUN_IDX}.out -covermode=atomic"
fi
RUN_IDX=$((RUN_IDX + 1))
GOTESTSUM_JUNITFILE="$junitfile" GOTESTSUM_JSONFILE="$jsonfile" \
"$GOBIN/gotestsum" --format "${GOTESTSUM_FORMAT:-testname}" --rerun-fails=3 --packages="$1" \
-- $GOFLAGS_BASE $RACE_FLAG $coverage_flag $run_flag \
|| FAILURES=$((FAILURES + 1))
}
# ── Read shard assignments ──
SHARD_TE=""
SHARD_EE=""
HEAVY_RUNS=""
if [[ -f shard-te-packages.txt ]]; then
SHARD_TE=$(cat shard-te-packages.txt)
fi
if [[ -f shard-ee-packages.txt ]]; then
SHARD_EE=$(cat shard-ee-packages.txt)
fi
if [[ -f shard-heavy-runs.txt && -s shard-heavy-runs.txt ]]; then
HEAVY_RUNS=$(cat shard-heavy-runs.txt)
fi
# ── Run light packages (single invocation, no -run filter) ──
ALL_LIGHT="${SHARD_TE} ${SHARD_EE}"
ALL_LIGHT="${ALL_LIGHT## }"
ALL_LIGHT="${ALL_LIGHT%% }"
if [[ -n "$ALL_LIGHT" ]]; then
LIGHT_COUNT=$(echo "$ALL_LIGHT" | wc -w)
echo "Running $LIGHT_COUNT light packages..."
run_gotestsum "$ALL_LIGHT"
fi
# ── Run heavy package splits (one invocation per package subset) ──
if [[ -n "$HEAVY_RUNS" ]]; then
while IFS= read -r line; do
[[ -z "$line" ]] && continue
PKG="${line%% *}"
REGEX="${line#* }"
SHORT_PKG="${PKG##*/}"
TEST_COUNT=$(echo "$REGEX" | tr '|' '\n' | wc -l)
echo "Running $TEST_COUNT tests from $SHORT_PKG..."
run_gotestsum "$PKG" "$REGEX"
done <<< "$HEAVY_RUNS"
fi
# ── Merge results from all runs ──
echo "Merging results from $RUN_IDX gotestsum runs..."
if ls report-*.xml 1>/dev/null 2>&1; then
# Simple XML concatenation — the merge job uses junit-report-merger for proper merging
head -1 report-0.xml > report.xml
echo "<testsuites>" >> report.xml
for f in report-*.xml; do
grep -v "<?xml" "$f" | grep -v "^<testsuites" | grep -v "^</testsuites" >> report.xml || true
done
echo "</testsuites>" >> report.xml
fi
cat gotestsum-*.json > gotestsum.json 2>/dev/null || true
# ── Merge coverage profiles within this shard (if coverage is enabled) ──
# A single shard may run multiple gotestsum invocations (light packages +
# heavy package splits), each producing its own cover-N.out. This merges
# them into one cover.out per shard. The cross-shard merge (combining all
# shards into a single report) is handled by Codecov's after_n_builds.
if [[ "${ENABLE_COVERAGE:-false}" == "true" ]] && ls cover-*.out 1>/dev/null 2>&1; then
echo "Merging coverage profiles..."
{
head -1 cover-0.out # "mode: atomic" header
tail -q -n +2 cover-*.out # data lines from all files
} > cover.out
echo "Merged $(ls cover-*.out | wc -l) coverage files into cover.out"
fi
if [[ $FAILURES -gt 0 ]]; then
echo "Shard complete: $RUN_IDX gotestsum runs, $FAILURES failed"
exit 1
fi
echo "Shard complete: $RUN_IDX gotestsum runs, all passed"

View File

@@ -0,0 +1,294 @@
#!/usr/bin/env node
/**
* shard-split.js — Test shard assignment solver
*
* Splits Go test packages across N parallel CI runners using timing data
* from previous runs. Uses a two-tier strategy:
*
* 1. "Light" packages (< HEAVY_MS total runtime): assigned whole to a shard
* 2. "Heavy" packages (>= HEAVY_MS): individual tests distributed across
* shards using -run regex filters
*
* Timing data sources (in priority order):
* - gotestsum.json (JSONL): per-test elapsed times from previous run
* - prev-report.xml (JUnit XML): package-level timing (fallback)
* - Round-robin: when no timing data exists at all
*
* Assignment algorithm: greedy bin-packing (sort by duration desc, assign
* each item to the shard with lowest current load). Simple and effective
* for our distribution where 2 packages dominate 84% of runtime.
*
* Environment variables:
* SHARD_INDEX — this runner's index (0-based)
* SHARD_TOTAL — total number of shards
*
* Input files (in working directory):
* all-packages.txt — newline-separated list of all test packages
* prev-gotestsum.json — (optional) JSONL timing data from previous run
* prev-report.xml — (optional) JUnit XML from previous run
*
* Output files (in working directory):
* shard-te-packages.txt — space-separated TE packages for this shard
* shard-ee-packages.txt — space-separated EE packages for this shard
* shard-heavy-runs.txt — heavy package runs, one per line: "pkg REGEX"
*/
const fs = require("node:fs");
const { execSync } = require("node:child_process");
const SHARD_INDEX = parseInt(process.env.SHARD_INDEX);
const SHARD_TOTAL = parseInt(process.env.SHARD_TOTAL);
const HEAVY_MS = 600000; // 600s (10 min): packages above this get test-level splitting
// Packages that should always be split test-by-test, even on a cold cache.
// Without timing data the splitter falls through to alphabetical round-robin,
// which places these adjacent on the same runner and overwhelms postgres.
// Forcing them heavy lets `go test -list` enumerate their tests so the
// bin-packer can spread them across all shards.
const KNOWN_HEAVY_PKGS = new Set([
"github.com/mattermost/mattermost/server/v8/channels/api4",
"github.com/mattermost/mattermost/server/v8/channels/app",
]);
if (isNaN(SHARD_INDEX) || isNaN(SHARD_TOTAL) || SHARD_TOTAL < 1) {
console.error("ERROR: SHARD_INDEX and SHARD_TOTAL must be set");
process.exit(1);
}
const allPkgs = fs
.readFileSync("all-packages.txt", "utf8")
.trim()
.split("\n")
.filter(Boolean);
if (allPkgs.length === 0) {
console.error("WARNING: No test packages found in all-packages.txt");
process.exit(0);
}
const pkgTimes = {};
const testTimes = {}; // "pkg::TestName" -> ms
// ── Parse gotestsum.json (JSONL) for per-test timing ──
// Each line is a JSON event; we want "pass" events with Elapsed times.
if (fs.existsSync("prev-gotestsum.json")) {
console.log("::group::Parsing gotestsum.json timing data");
const lines = fs.readFileSync("prev-gotestsum.json", "utf8").split("\n");
for (const line of lines) {
if (!line.includes('"pass"')) continue;
try {
const d = JSON.parse(line);
if (!d.Test || !d.Package) continue;
const elapsed = Math.round((d.Elapsed || 0) * 1000);
// Aggregate package time from test pass events
pkgTimes[d.Package] = (pkgTimes[d.Package] || 0) + elapsed;
// Top-level test name (use max elapsed for parent vs subtests)
const top = d.Test.split("/")[0];
const key = d.Package + "::" + top;
testTimes[key] = Math.max(testTimes[key] || 0, elapsed);
} catch (e) {
// Skip malformed lines
}
}
console.log(
`gotestsum.json: ${Object.keys(pkgTimes).length} packages, ${Object.keys(testTimes).length} tests`,
);
console.log("::endgroup::");
}
// ── Fallback: parse JUnit XML for package-level timing ──
if (Object.keys(pkgTimes).length === 0 && fs.existsSync("prev-report.xml")) {
console.log("::group::Parsing JUnit XML timing data (fallback)");
const xml = fs.readFileSync("prev-report.xml", "utf8");
for (const m of xml.matchAll(/<testsuite[^>]*>/g)) {
const name = m[0].match(/name="([^"]+)"/)?.[1];
const time = m[0].match(/\btime="([^"]+)"/)?.[1];
if (name && time) {
pkgTimes[name] =
(pkgTimes[name] || 0) + Math.round(parseFloat(time) * 1000);
}
}
console.log(
`JUnit XML: ${Object.keys(pkgTimes).length} packages (no per-test data)`,
);
console.log("::endgroup::");
}
const hasTimingData = Object.keys(pkgTimes).length > 0;
const hasTestTiming = Object.keys(testTimes).length > 0;
// ── Identify heavy packages ──
// Split at test level for packages above HEAVY_MS (requires per-test timing)
// AND for the KNOWN_HEAVY_PKGS list (which uses go test -list discovery
// to enumerate tests when no timing cache exists).
//
// Both checks gate on allPkgs membership so stale entries from the cached
// pkgTimes (renamed/deleted packages from a prior run) can't end up in
// heavyPkgs — otherwise the post-discovery fallback would emit them as
// whole-package items for nonexistent packages.
const allPkgsSet = new Set(allPkgs);
const heavyPkgs = new Set();
if (hasTestTiming) {
for (const [pkg, ms] of Object.entries(pkgTimes)) {
if (ms > HEAVY_MS && allPkgsSet.has(pkg)) heavyPkgs.add(pkg);
}
}
for (const pkg of allPkgs) {
if (KNOWN_HEAVY_PKGS.has(pkg)) heavyPkgs.add(pkg);
}
if (heavyPkgs.size > 0) {
console.log("Heavy packages (test-level splitting):");
for (const p of heavyPkgs) {
const t = pkgTimes[p];
const label = t ? `${(t / 1000).toFixed(0)}s` : "no-timing";
console.log(` ${label} ${p.split("/").pop()}`);
}
}
// ── Build work items ──
// Each item is either a whole package ("P") or a single test from a heavy package ("T")
const items = [];
for (const pkg of allPkgs) {
if (heavyPkgs.has(pkg)) {
// Split into individual test items
const tests = Object.entries(testTimes)
.filter(([k]) => k.startsWith(pkg + "::"))
.map(([k, ms]) => ({ ms, type: "T", pkg, test: k.split("::")[1] }));
if (tests.length > 0) {
items.push(...tests);
}
// If no per-test timing exists, the discovery step below enumerates
// tests via `go test -list`. A final fallback to whole-package is
// added after discovery for packages where both lookups failed.
} else {
items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
}
// ── Discover new/renamed tests in heavy packages ──
// Tests not in the timing cache won't appear in any shard's -run regex,
// silently skipping them. Discover current test names at runtime and
// assign any cache-missing tests to the least-loaded shard.
if (heavyPkgs.size > 0) {
console.log("::group::Discovering new tests in heavy packages");
for (const pkg of heavyPkgs) {
const cachedTests = new Set(
Object.keys(testTimes)
.filter((k) => k.startsWith(pkg + "::"))
.map((k) => k.split("::")[1]),
);
try {
const out = execSync(`go test -list '.*' ${pkg} 2>/dev/null`, {
encoding: "utf8",
timeout: 300000,
});
const currentTests = out
.split("\n")
.map((l) => l.trim())
.filter((l) => /^Test[A-Z]/.test(l));
let newCount = 0;
for (const t of currentTests) {
if (!cachedTests.has(t)) {
// Assign a small default duration so it gets picked up
items.push({ ms: 1000, type: "T", pkg, test: t });
newCount++;
}
}
if (newCount > 0) {
console.log(
` ${pkg.split("/").pop()}: ${newCount} new test(s) not in cache`,
);
}
} catch (e) {
// go test -list can fail for packages whose TestMain requires a DB
// connection (e.g. sqlstore) because the GitHub runner cannot reach the
// docker-compose postgres network. Log a warning and fall back to
// treating the package as a whole unit rather than failing all shards.
console.error(
`::warning::${pkg.split("/").pop()}: go test -list failed — treating as whole package (new tests may be skipped this run). ${e.message}`,
);
}
}
// Ensure every heavy package has at least one item. A package can reach
// this point with zero items if it has no per-test timing AND `go test
// -list` failed (e.g. sqlstore on a cold cache).
for (const pkg of heavyPkgs) {
const hasItems = items.some((it) => it.pkg === pkg);
if (!hasItems) {
console.log(
` ${pkg.split("/").pop()}: no per-test data, running as whole package`,
);
items.push({ ms: pkgTimes[pkg] || 1, type: "P", pkg });
}
}
console.log("::endgroup::");
}
// Sort descending by duration for greedy bin-packing
items.sort((a, b) => b.ms - a.ms);
// ── Greedy bin-packing assignment ──
const shards = Array.from({ length: SHARD_TOTAL }, () => ({
load: 0,
whole: [],
heavy: {},
}));
if (!hasTimingData && heavyPkgs.size === 0) {
// Round-robin fallback only when we have *no* signal — no timing cache
// and no known-heavy packages to test-level-split. With heavyPkgs we
// can still bin-pack: discovered tests (ms=1000 each) drive the
// distribution and whole-package items (ms=1) fill in evenly.
console.log("No timing data — using round-robin");
allPkgs.forEach((pkg, i) => {
shards[i % SHARD_TOTAL].whole.push(pkg);
});
} else {
for (const item of items) {
// Find shard with minimum current load
const min = shards.reduce(
(m, s, i) => (s.load < shards[m].load ? i : m),
0,
);
shards[min].load += item.ms;
if (item.type === "P") {
shards[min].whole.push(item.pkg);
} else {
if (!shards[min].heavy[item.pkg]) shards[min].heavy[item.pkg] = [];
shards[min].heavy[item.pkg].push(item.test);
}
}
}
// ── Report shard assignments ──
console.log("::group::Shard assignment");
for (let i = 0; i < SHARD_TOTAL; i++) {
const s = shards[i];
const hRuns = Object.keys(s.heavy).length;
const hTests = Object.values(s.heavy).reduce((n, a) => n + a.length, 0);
const marker = i === SHARD_INDEX ? " ← THIS SHARD" : "";
console.log(
`Shard ${i}: ${(s.load / 1000).toFixed(1)}s | ${s.whole.length} pkgs` +
(hRuns > 0 ? `, ${hRuns} heavy splits (${hTests} tests)` : "") +
marker,
);
}
console.log("::endgroup::");
// ── Write output for this shard ──
const myShard = shards[SHARD_INDEX];
const te = myShard.whole.filter((p) => !p.includes("/enterprise/")).join(" ");
const ee = myShard.whole.filter((p) => p.includes("/enterprise/")).join(" ");
fs.writeFileSync("shard-te-packages.txt", te);
fs.writeFileSync("shard-ee-packages.txt", ee);
// Heavy package runs: one line per run as "pkg REGEX"
const heavyRuns = Object.entries(myShard.heavy).map(([pkg, tests]) => {
const regex = tests.map((t) => "^" + t + "$").join("|");
return pkg + " " + regex;
});
fs.writeFileSync("shard-heavy-runs.txt", heavyRuns.join("\n"));
console.log(
`Light packages: ${myShard.whole.length} (${te.split(" ").filter(Boolean).length} TE, ${ee.split(" ").filter(Boolean).length} EE)`,
);
console.log(`Heavy package runs: ${heavyRuns.length}`);

View File

@@ -0,0 +1,333 @@
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const os = require("node:os");
const SCRIPT = path.join(__dirname, "shard-split.js");
const TESTDATA = path.join(__dirname, "testdata");
/**
* Helper: run shard-split.js in a temp directory with given inputs.
* Returns the output files and stdout.
*/
function runSolver({ packages, shardIndex, shardTotal, gotestsumJson, prevReportXml }) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "shard-test-"));
try {
fs.writeFileSync(path.join(tmpDir, "all-packages.txt"), packages.join("\n"));
if (gotestsumJson) {
fs.writeFileSync(path.join(tmpDir, "prev-gotestsum.json"), gotestsumJson);
}
if (prevReportXml) {
fs.writeFileSync(path.join(tmpDir, "prev-report.xml"), prevReportXml);
}
const stdout = execFileSync("node", [SCRIPT], {
cwd: tmpDir,
env: {
...process.env,
SHARD_INDEX: String(shardIndex),
SHARD_TOTAL: String(shardTotal),
},
encoding: "utf8",
});
const te = fs.readFileSync(path.join(tmpDir, "shard-te-packages.txt"), "utf8");
const ee = fs.readFileSync(path.join(tmpDir, "shard-ee-packages.txt"), "utf8");
const heavy = fs.readFileSync(path.join(tmpDir, "shard-heavy-runs.txt"), "utf8");
return { te, ee, heavy, stdout };
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}
}
describe("shard-split.js", () => {
describe("round-robin fallback (no timing data)", () => {
it("distributes packages evenly across shards", () => {
const packages = [
"github.com/mattermost/mattermost/server/v8/channels/api4",
"github.com/mattermost/mattermost/server/v8/channels/app",
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore",
"github.com/mattermost/mattermost/server/v8/config",
];
// Collect assignments from all shards
const allTe = [];
for (let i = 0; i < 2; i++) {
const result = runSolver({ packages, shardIndex: i, shardTotal: 2 });
allTe.push(...result.te.split(" ").filter(Boolean));
}
// All packages should be assigned exactly once
assert.equal(allTe.sort().join("\n"), packages.sort().join("\n"));
});
it("uses round-robin when no timing files exist", () => {
const packages = ["pkg/a", "pkg/b", "pkg/c", "pkg/d", "pkg/e"];
const r0 = runSolver({ packages, shardIndex: 0, shardTotal: 2 });
const r1 = runSolver({ packages, shardIndex: 1, shardTotal: 2 });
assert.ok(r0.stdout.includes("round-robin"), "Should mention round-robin in output");
// No heavy runs
assert.equal(r0.heavy.trim(), "");
assert.equal(r1.heavy.trim(), "");
});
});
describe("timing-based balancing", () => {
it("balances shards using gotestsum.json timing data", () => {
const gotestsumJson = fs.readFileSync(
path.join(TESTDATA, "sample-gotestsum.json"),
"utf8"
);
const packages = [
"github.com/mattermost/mattermost/server/v8/channels/api4",
"github.com/mattermost/mattermost/server/v8/channels/app",
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore",
"github.com/mattermost/mattermost/server/v8/config",
"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch",
"github.com/mattermost/mattermost/server/v8/enterprise/compliance",
"github.com/mattermost/mattermost/server/public/model",
];
// Run for all 4 shards and check that loads are somewhat balanced
const loads = [];
const allAssigned = new Set();
for (let i = 0; i < 4; i++) {
const result = runSolver({
packages,
shardIndex: i,
shardTotal: 4,
gotestsumJson,
});
// Track all assigned packages and tests
const tePkgs = result.te.split(" ").filter(Boolean);
const eePkgs = result.ee.split(" ").filter(Boolean);
tePkgs.forEach((p) => allAssigned.add(p));
eePkgs.forEach((p) => allAssigned.add(p));
// Parse heavy runs
if (result.heavy.trim()) {
result.heavy
.trim()
.split("\n")
.forEach((line) => {
const pkg = line.split(" ")[0];
allAssigned.add(pkg);
});
}
}
// Every package should be covered
for (const pkg of packages) {
assert.ok(
allAssigned.has(pkg),
`Package ${pkg} should be assigned to some shard`
);
}
});
it("does not produce empty shards with sample data", () => {
const gotestsumJson = fs.readFileSync(
path.join(TESTDATA, "sample-gotestsum.json"),
"utf8"
);
const packages = [
"github.com/mattermost/mattermost/server/v8/channels/api4",
"github.com/mattermost/mattermost/server/v8/channels/app",
"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore",
"github.com/mattermost/mattermost/server/v8/config",
];
for (let i = 0; i < 4; i++) {
const result = runSolver({
packages,
shardIndex: i,
shardTotal: 4,
gotestsumJson,
});
const hasWork =
result.te.trim() !== "" ||
result.ee.trim() !== "" ||
result.heavy.trim() !== "";
assert.ok(hasWork, `Shard ${i} should have some work assigned`);
}
});
});
describe("heavy package splitting", () => {
it("splits packages over HEAVY_MS threshold into individual tests", () => {
// Create timing data where api4 is very heavy (> 300s = 300000ms)
const lines = [];
// api4: 6 tests totaling 452.2s (> 300s threshold)
for (const [test, elapsed] of [
["TestGetChannel", 145.2],
["TestCreatePost", 98.1],
["TestUpdateChannel", 72.5],
["TestDeleteChannel", 58.3],
["TestGetChannelMembers", 45.7],
["TestSearchChannels", 32.4],
]) {
lines.push(
JSON.stringify({
Time: "2025-03-20T10:00:00Z",
Action: "pass",
Package: "github.com/mattermost/mattermost/server/v8/channels/api4",
Test: test,
Elapsed: elapsed,
})
);
}
// config: 2 tests totaling 8s (< 120s, stays whole)
for (const [test, elapsed] of [
["TestConfigStore", 5.0],
["TestConfigMigrate", 3.0],
]) {
lines.push(
JSON.stringify({
Time: "2025-03-20T10:00:00Z",
Action: "pass",
Package: "github.com/mattermost/mattermost/server/v8/config",
Test: test,
Elapsed: elapsed,
})
);
}
const gotestsumJson = lines.join("\n");
const packages = [
"github.com/mattermost/mattermost/server/v8/channels/api4",
"github.com/mattermost/mattermost/server/v8/config",
];
// With 2 shards, api4 tests should be split across shards
let heavyFound = false;
const allHeavyTests = [];
for (let i = 0; i < 2; i++) {
const result = runSolver({
packages,
shardIndex: i,
shardTotal: 2,
gotestsumJson,
});
if (result.heavy.trim()) {
heavyFound = true;
// Parse heavy runs to extract test names
for (const line of result.heavy.trim().split("\n")) {
const parts = line.split(" ");
assert.equal(
parts[0],
"github.com/mattermost/mattermost/server/v8/channels/api4",
"Heavy package should be api4"
);
// Regex is like "^TestGetChannel$|^TestCreatePost$"
const tests = parts[1].split("|").map((r) => r.replace(/[\^$]/g, ""));
allHeavyTests.push(...tests);
}
}
}
assert.ok(heavyFound, "Should have heavy package splits for api4");
// All api4 tests should be distributed
const expectedTests = [
"TestGetChannel",
"TestCreatePost",
"TestUpdateChannel",
"TestDeleteChannel",
"TestGetChannelMembers",
"TestSearchChannels",
];
assert.deepEqual(
allHeavyTests.sort(),
expectedTests.sort(),
"All api4 tests should be distributed across shards"
);
});
it("keeps light packages whole even with timing data", () => {
const gotestsumJson = [
'{"Action":"pass","Package":"pkg/light","Test":"TestA","Elapsed":5.0}',
'{"Action":"pass","Package":"pkg/light","Test":"TestB","Elapsed":3.0}',
].join("\n");
const result = runSolver({
packages: ["pkg/light"],
shardIndex: 0,
shardTotal: 2,
gotestsumJson,
});
// Light package should be assigned whole, not split
assert.equal(result.heavy.trim(), "", "Light package should not be in heavy runs");
assert.ok(
result.te.includes("pkg/light"),
"Light package should be in TE packages"
);
});
});
describe("JUnit XML fallback", () => {
it("uses JUnit XML when gotestsum.json is missing", () => {
const prevReportXml = `<?xml version="1.0" encoding="UTF-8"?>
<testsuites>
<testsuite name="pkg/fast" time="10.0" tests="5">
<testcase name="TestA" time="5.0"/>
<testcase name="TestB" time="5.0"/>
</testsuite>
<testsuite name="pkg/slow" time="50.0" tests="3">
<testcase name="TestX" time="25.0"/>
<testcase name="TestY" time="25.0"/>
</testsuite>
</testsuites>`;
const result = runSolver({
packages: ["pkg/fast", "pkg/slow"],
shardIndex: 0,
shardTotal: 2,
prevReportXml,
});
assert.ok(
result.stdout.includes("JUnit XML"),
"Should indicate using JUnit XML fallback"
);
// No heavy splits with XML-only data (no per-test timing)
assert.equal(result.heavy.trim(), "", "Should not split packages without per-test timing");
});
});
describe("enterprise package separation", () => {
it("separates enterprise packages into EE output", () => {
const packages = [
"github.com/mattermost/mattermost/server/v8/channels/app",
"github.com/mattermost/mattermost/server/v8/enterprise/compliance",
];
const result = runSolver({
packages,
shardIndex: 0,
shardTotal: 1,
});
assert.ok(
result.te.includes("channels/app"),
"TE should include non-enterprise packages"
);
assert.ok(
result.ee.includes("enterprise/compliance"),
"EE should include enterprise packages"
);
assert.ok(
!result.te.includes("enterprise"),
"TE should not include enterprise packages"
);
});
});
});

View File

@@ -0,0 +1,22 @@
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestGetChannel","Elapsed":45.2}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestCreatePost","Elapsed":38.1}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestUpdateChannel","Elapsed":22.5}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestDeleteChannel","Elapsed":18.3}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestGetChannelMembers","Elapsed":15.7}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestSearchChannels","Elapsed":12.4}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/app","Test":"TestCreateUser","Elapsed":25.6}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/app","Test":"TestUpdateUser","Elapsed":20.3}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/app","Test":"TestDeleteUser","Elapsed":15.8}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/app","Test":"TestGetUser","Elapsed":10.2}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/app","Test":"TestSearchUsers","Elapsed":8.5}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore","Test":"TestChannelStore","Elapsed":35.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore","Test":"TestPostStore","Elapsed":28.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/channels/store/sqlstore","Test":"TestUserStore","Elapsed":22.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/config","Test":"TestConfigStore","Elapsed":5.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/config","Test":"TestConfigMigrate","Elapsed":3.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch","Test":"TestSearchPosts","Elapsed":8.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/enterprise/elasticsearch","Test":"TestIndexPosts","Elapsed":6.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/v8/enterprise/compliance","Test":"TestExportCompliance","Elapsed":4.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/public/model","Test":"TestModelValidation","Elapsed":2.0}
{"Time":"2025-03-20T10:00:00Z","Action":"pass","Package":"github.com/mattermost/mattermost/server/public/model","Test":"TestModelSerialization","Elapsed":1.5}
{"Time":"2025-03-20T10:00:00Z","Action":"output","Package":"github.com/mattermost/mattermost/server/v8/channels/api4","Test":"TestGetChannel","Output":"--- PASS: TestGetChannel (45.20s)\n"}

24
server/scripts/vet-api-check.sh Executable file
View File

@@ -0,0 +1,24 @@
#!/bin/bash
set -euo pipefail
IFS=$'\n\t'
API_YAML=$ROOT../api/v4/html/static/mattermost-openapi-v4.yaml
OUTPUT=$($GO vet -vettool=$GOBIN/mattermost-govet -openApiSync -openApiSync.spec=$API_YAML ./... 2>&1 || true)
echo "OpenAPI vet output"
echo "=================="
echo "$OUTPUT"
OUTPUT_EXCLUDING_IGNORED=$(echo "$OUTPUT" | grep -Fv \
-e 'go: downloading' \
2>&1 || true)
if [[ ! -z "${OUTPUT_EXCLUDING_IGNORED// }" ]]; then
echo "Failing vet output"
echo "=================="
echo "$OUTPUT_EXCLUDING_IGNORED"
exit 1
else
echo "openApiSync passed."
exit 0
fi

View File

@@ -0,0 +1,20 @@
#!/bin/bash
total=0
MAX_WAIT_SECONDS="${MAX_WAIT_SECONDS:=60}"
echo "waiting $MAX_WAIT_SECONDS seconds for the server to start"
while [[ "$total" -le "$MAX_WAIT_SECONDS" ]]; do
if bin/mmctl system status --local 2> /dev/null; then
exit 0
else
((total=total+1))
printf "."
sleep 1
fi
done
printf "\nserver didn't start in $MAX_WAIT_SECONDS seconds\n"
exit 1