Skip to content
Snippets Groups Projects
Commit 6e009ef5 authored by Hahn Axel (hahn)'s avatar Hahn Axel (hahn)
Browse files

Merge branch '5927-overide-phase-per-project' into 'master'

support custom phase per project

See merge request !1
parents 2af118cc 3d1b106f
No related branches found
No related tags found
1 merge request!1support custom phase per project
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
# #
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# 2013-11-06 axel.hahn@iml.unibe.ch # 2013-11-06 axel.hahn@iml.unibe.ch
# 2022-11-25 axel.hahn@unibe.ch do not check json in target dir; shellfixes
# ====================================================================== # ======================================================================
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
...@@ -41,8 +42,8 @@ ...@@ -41,8 +42,8 @@
echo echo
if [ $# -lt 2 ]; then if [ $# -lt 2 ]; then
echo Syntax is echo "Syntax is"
echo `basename $0` [webroot] [tgz-archive] [[deleteflag]] echo "$( basename $0 ) [webroot] [tgz-archive] [[deleteflag]]"
exit 1 exit 1
fi fi
...@@ -58,10 +59,12 @@ ...@@ -58,10 +59,12 @@
# see #2235 - for nodejs apps the target dir is different # see #2235 - for nodejs apps the target dir is different
# ls -ld $prjdir/public_html/ || ls -ld $prjdir/public/ || exit 2 # ls -ld $prjdir/public_html/ || ls -ld $prjdir/public/ || exit 2
# echo $prjdir | fgrep "/www/" || exit 2 # echo $prjdir | fgrep "/www/" || exit 2
ls -l $prjdir/*.json || exit 2
# skip this - aum spider has no json
# ls -l $prjdir/*.json || exit 2
# check tgz # check tgz
file $tgz | fgrep "gzip compressed" || exit 3 file $tgz | grep -F "gzip compressed" || exit 3
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
# MAIN # MAIN
...@@ -97,9 +100,9 @@ ...@@ -97,9 +100,9 @@
bDelete=1 bDelete=1
testfile=$delfile testfile=$delfile
if [ ! -d $delfile ]; then if [ ! -d $delfile ]; then
testfile=`dirname $delfile` testfile=$( dirname $delfile )
fi fi
echo $testfile | fgrep -f $filelist >/dev/null echo $testfile | grep -F -f $filelist >/dev/null
if [ $? -eq 0 ]; then if [ $? -eq 0 ]; then
echo KEEP: $delfile echo KEEP: $delfile
else else
......
...@@ -12,6 +12,7 @@ ...@@ -12,6 +12,7 @@
# 2021-11-01 v0.6 <axel.hahn@iml.unibe.ch> save config diffs # 2021-11-01 v0.6 <axel.hahn@iml.unibe.ch> save config diffs
# 2021-11-02 v0.7 <axel.hahn@iml.unibe.ch> delete logs keping N files # 2021-11-02 v0.7 <axel.hahn@iml.unibe.ch> delete logs keping N files
# 2022-11-24 v0.8 <axel.hahn@iml.unibe.ch> tar -xzf without dot as 2nd param # 2022-11-24 v0.8 <axel.hahn@iml.unibe.ch> tar -xzf without dot as 2nd param
# 2022-11-25 v0.9 <axel.hahn@iml.unibe.ch> support custom phase + file per project
# ====================================================================== # ======================================================================
...@@ -20,6 +21,7 @@ ...@@ -20,6 +21,7 @@
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
cd $( dirname $0 ) cd $( dirname $0 )
selfdir=$( /bin/pwd ) selfdir=$( /bin/pwd )
_version=0.9
tmpdir=/var/tmp/imldeployment_packages tmpdir=/var/tmp/imldeployment_packages
logdir=/var/log/imldeployment-client logdir=/var/log/imldeployment-client
...@@ -74,9 +76,17 @@ function setprofile(){ ...@@ -74,9 +76,17 @@ function setprofile(){
fi fi
echo "OK, profile [${profile}] was set." echo "OK, profile [${profile}] was set."
downloadfile="${tmpdir}/${IMLCI_PROJECT}.tgz" local localfile
downloadtmp="${tmpdir}/${IMLCI_PROJECT}.tgz.tmp" if [ -n "$IMLCI_FILE" ]; then
cfgdiff="${tmpdir}/${IMLCI_PROJECT}_cfgdiff.txt" localfile="${IMLCI_PROJECT}__${IMLCI_FILE}"
else
IMLCI_FILE="${IMLCI_PROJECT}.tgz"
localfile="${IMLCI_FILE}"
fi
downloadfile="${tmpdir}/${localfile}"
downloadtmp="${tmpdir}/${localfile}.tmp"
cfgdiff="${tmpdir}/${localfile}_cfgdiff.txt"
test -f "${cfgdiff}" && rm -f "${cfgdiff}" test -f "${cfgdiff}" && rm -f "${cfgdiff}"
} }
...@@ -132,6 +142,7 @@ function run_task(){ ...@@ -132,6 +142,7 @@ function run_task(){
function deploy(){ function deploy(){
local dlparams
skipmessage="SKIP: no newer download file. You can use parameter -f to force reinstall." skipmessage="SKIP: no newer download file. You can use parameter -f to force reinstall."
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
...@@ -140,9 +151,17 @@ function deploy(){ ...@@ -140,9 +151,17 @@ function deploy(){
# ---------------------------------------------------------------------- # ----------------------------------------------------------------------
header "Download ${IMLCI_PROJECT}.tgz" header "Download ${IML} ${IMLCI_PROJECT}.tgz"
typeset -i local isupdate=$defaultupdate typeset -i local isupdate=$defaultupdate
${selfdir}/bin/getfile.sh -f ${IMLCI_PROJECT}.tgz -o ${downloadtmp}
# getfile.sh reads phase from its cfg file - we need to add it as parameter
test -n "${IMLCI_PHASE}" && dlparams="$dlparams -e ${IMLCI_PHASE}"
# set the filename to fetch
test -n "$IMLCI_FILE" || dlparams="$dlparams -f ${IMLCI_PROJECT}.tgz"
test -n "$IMLCI_FILE" && dlparams="$dlparams -f $IMLCI_FILE"
${selfdir}/bin/getfile.sh ${dlparams} -o ${downloadtmp}
if [ $? -ne 0 ]; then if [ $? -ne 0 ]; then
echo Download failed. echo Download failed.
echo Repeating request with debug param -d to get the error... echo Repeating request with debug param -d to get the error...
...@@ -301,28 +320,32 @@ cd $( dirname $0 ) ...@@ -301,28 +320,32 @@ cd $( dirname $0 )
action="deploy" action="deploy"
typeset -i defaultupdate=0 typeset -i defaultupdate=0
echo echo "_______________________________________________________________________________
echo
echo "<<<<<<<<<<##########| IML - DEPLOYMENT SCRIPT |##########>>>>>>>>>>" IML - DEPLOYMENT CLIENT
echo DOCS: https://os-docs.iml.unibe.ch/imldeployment-client/ _____
_________________________________________________________________________/ v$_version
"
while getopts 'hfl' arg; do while getopts 'hfl' arg; do
case ${arg} in case ${arg} in
h) h)
echo "HELP" echo "HELP:"
echo "Load one or more profiles profile to deploy an application." echo " Loads one or more profiles profile to deploy an application."
echo "If the download file is not newer then it does not extract files and does not" echo " If the download file is not newer then it does not extract files and does not"
echo "run pre and post hooks - it updates the config files only and sets the owner." echo " Optionally it cleans up the target directory."
echo " Runs pre and post hooks - it updates the config files only and sets the owner."
echo echo
echo "Syntax:" echo "SYNTAX:"
echo " $( basename $0 ) [OPTION] [PROFILE(S)]" echo " $( basename $0 ) [OPTION] [PROFILE(S)]"
echo echo
echo "Optioms:" echo "OPTIONS:"
echo " -h | show this help and exit" echo " -h | show this help and exit"
echo " -f | force full installation even if the download file is not newer" echo " -f | force full installation even if the download file is not newer"
echo " -l | list exiting profile names" echo " -l | list exiting profile names"
echo echo
echo "Profile(s):" echo "PROFILE(S):"
echo " Set one or more valid profile names. By default it loops over all profiles." echo " Set one or more valid profile names. By default it loops over all profiles."
echo " This prameter limits the execution to the given profiles." echo " This prameter limits the execution to the given profiles."
echo " Use option -l to get a list of profiles." echo " Use option -l to get a list of profiles."
......
## Requirements
A few Linux standard tools are required. It should run on any GNU Linux.
* Bash
* Curl
* sed
Tested environments:
* CentOS
* Debian
* Manjaro
* Ubuntu
## Features
* Secure download from CI package server using shared secret
* detect a changed download to run an upgrade or just apply rules for configs
* Extraction to target dir
* Cleanup: delete all files in target dir that are not included in the software package
* Create (config) files by inserting strings into placeholders
* Several hooks to allow custom actions, eg. restart a service un changes
* Handle multiple applications on a machine (aka profiles)
* Writes a logfile per execution and application (and to stdout)
# IML deployment client
This client is a set of bash scripts to deploy a package that was built on th IML CI server.
```mermaid
graph LR
%% ----- STYLING
style CIserver fill:none,stroke:#aaa,stroke-dasharray: 3 5
style DeployClient fill:#8e8,stroke:#4a3,stroke-width:2px
%% ----- GRAPH
subgraph CIserver
CI(CI<br>deployment<br>web gui) --> |Build| PkgDir
PkgDir[Package<br>dir]
end
PkgDir --> |rsync| Pkg1
PkgDir --> |rsync| Pkg2
PkgDir --> |rsync| Pkg3
Pkg1(CI package<br>server 1) --> |secure<br>download| DeployClient
Pkg2(CI package<br>server N)
Pkg3(Puppet master)
subgraph Appserver
DeployClient --> |installs| ApplicationA(Application A)
DeployClient --> |installs| ApplicationB(Application B)
end
```
You need to put the files to the appliation servers. The task of the deployment client are:
* fetch software with a secure download
* extract the packages
* generates configs by replacing variables in templates
This project is related to
* CI deployment web gui <https://git-repo.iml.unibe.ch/iml-open-source/imldeployment> - it builds the software packages
* CI package server <https://git-repo.iml.unibe.ch/iml-open-source/ci-pkg> - a sattelite with all build packages
File moved
File moved
...@@ -7,24 +7,27 @@ This is the main deployment script. ...@@ -7,24 +7,27 @@ This is the main deployment script.
```txt ```txt
./deploy_app.sh -h ./deploy_app.sh -h
_______________________________________________________________________________
IML - DEPLOYMENT CLIENT
DOCS: https://os-docs.iml.unibe.ch/imldeployment-client/ _____
_________________________________________________________________________/ v0.9
<<<<<<<<<<##########| IML - DEPLOYMENT SCRIPT |##########>>>>>>>>>> HELP:
Loads one or more profiles profile to deploy an application.
If the download file is not newer then it does not extract files and does not
Optionally it cleans up the target directory.
Runs pre and post hooks - it updates the config files only and sets the owner.
HELP SYNTAX:
Load one or more profiles profile to deploy an application.
If the download file is not newer then it does not extract files and does not
run pre and post hooks - it updates the config files only and sets the owner.
Syntax:
deploy_app.sh [OPTION] [PROFILE(S)] deploy_app.sh [OPTION] [PROFILE(S)]
Optioms: OPTIONS:
-h | show this help and exit -h | show this help and exit
-f | force full installation even if the download file is not newer -f | force full installation even if the download file is not newer
-l | list exiting profile names -l | list exiting profile names
Profile(s): PROFILE(S):
Set one or more valid profile names. By default it loops over all profiles. Set one or more valid profile names. By default it loops over all profiles.
This prameter limits the execution to the given profiles. This prameter limits the execution to the given profiles.
Use option -l to get a list of profiles. Use option -l to get a list of profiles.
......
# IML deployment client <html>
<div class="hero">
This client is a set of bash scripts to deploy a package that was built on th IML CI server. <h2>Welcome</h2>
It handles a secure download, extracts the package, generates configs. a set of bash scripts to deploy a package that was built on th IML CI server
</div>
```mermaid </html>
graph LR
CI(CI<br>deployment<br>web gui) --> |Build| PkgDir 📄 Source: <https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/> \
PkgDir[Package<br>dir] 📜 GNU GPL 3.0 \
📗 Docs: <https://os-docs.iml.unibe.ch/imldeployment-client/>
PkgDir --> |rsync| Pkg1 \ No newline at end of file
PkgDir --> |rsync| Pkg2
PkgDir --> |rsync| Pkg3
Pkg1(CI package<br>server 1) --> |secure<br>download| DeployClient
Pkg2(CI package<br>server N)
Pkg3(Puppet master)
subgraph Appserver
DeployClient --> |installs| ApplicationA(Application A)
DeployClient --> |installs| ApplicationB(Application B)
end
```
This project is related to
* CI deployment web gui <https://git-repo.iml.unibe.ch/iml-open-source/imldeployment>
* CI package server <https://git-repo.iml.unibe.ch/iml-open-source/ci-pkg>
## License
GNU GPL 3.0
## Source
URL: <https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/>
## Requirements
A few Linux standard tools are required. It should run on any GNU Linux.
* Bash
* Curl
* sed
Tested environments:
* CentOS
* Debian
* Manjaro
* Ubuntu
## Features
* Secure download from CI package server using shared secret
* detect a changed download to run an upgrade or just apply rules for configs
* Extraction to target dir
* Cleanup: delete all files in target dir that are not included in the software package
* Create (config) files by inserting strings into placeholders
* Several hooks to allow custom actions, eg. restart a service un changes
* Handle multiple applications on a machine (aka profiles)
* Writes a logfile per execution and application (and to stdout)
...@@ -17,7 +17,8 @@ ...@@ -17,7 +17,8 @@
"basepath": "https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/tree/master/docs" "basepath": "https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/tree/master/docs"
}, },
"links": { "links": {
"Git Repo": "https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/" "Git Repo": "https://git-repo.iml.unibe.ch/iml-open-source/imldeployment-client/",
"IML Opensource": "https://os-docs.iml.unibe.ch/"
}, },
"theme": "daux-blue", "theme": "daux-blue",
"search": true "search": true
......
/* /*
patch css elements of daux.io blue theme patch css elements of daux.io blue theme
version 2022-04-22 version 2022-11-22
*/ */
:root {
/* Axels Overrides */
/* ---------- vars ---------- */ --link-color: #37a;
--brand-color: var(--color-secondary);
:root{ --brand-background: var(--body-background);
--hr-color: none;
/* background colors */ --search-field-background: none;
--bg:none; --search-field-border-color: none;
--bg-body: #fff; --sidebar-background: var(--body-background);
--bg-navlinkactive:#f4f4f4; --sidebar-border-color: none;
--bg-navlinkactive: linear-gradient(-90deg,rgba(0,0,0,0), rgba(40,60,80,0.05) 30%); --sidebar-link-active-background: #e8f4f6;
--bg-pre:#f8f8f8; --sidebar-link-active-background: #eee;
--bg-toc: #fff; /* Axels custom values */
--axel_bg-toc: var(--body-background);
/* foreground colors */ --axel_bg-toc-head: #f8f8f8;
--color: #234; --axel_brand-background: none;
--navlinkactive:#f33; --axel_brand-pre-background: rgb(255, 0, 51);
--title: #aaa; ;
--axel_brand-pre-background-hover: rgb(255, 0, 51);
--link:#12a; ;
--toclink:rgba(40,60,80,0.8); --axel_h1_header: none;
--axel_h1: #345;
--h1: rgba(40,60,80,0.8); --axel_h1-bg: none;
--h1-bottom: 1px solid rgba(40,60,80,0.1); --axel_h1-bottom: 3px solid none;
--h2: rgba(40,60,80,0.5); --axel_h2: #27a;
--h3: rgba(40,60,80,0.3); --axel_h2-bg: #fafafa;
--axel_h2-bottom: 2px solid #8ce;
--axel_h2-hero-bottom: 2px solid #912;
--axel_h3: #838;
--axel_h3-bottom: 1px solid #ddd;
--axel_hero_bg: #f8f8f8;
--axel_nav-bg: #fcfcfc;
--axel_nav-buttomborder: #ddd;
--axel_pre-background: #f8f8f8;
--axel-th-background: #d0e0e8;
--axel-article-nav-border-top: 0px dotted #ddd;
} }
/* ---------- tags ---------- */ .dark {
/* Axels Overrides */
--color-text: #c0c0c0;
--link-color: #6bd;
--brand-color: var(--color-text);
--brand-background: var(--body-background);
--hr-color: none;
--code-tag-background-color_: #bcc;
--search-field-background: none;
--search-field-border-color: none;
--sidebar-background: var(--body-background);
--sidebar-border-color: none;
--sidebar-link-active-background: #333;
/* Axels custom values */
--axel_bg-toc: var(--body-background);
--axel_bg-toc-head: #333;
--axel_brand-background: none;
--axel_brand-pre-background: rgb(255, 0, 51);
;
--axel_brand-pre-background-hover: rgb(255, 0, 51);
;
--axel_h1_header: none;
--axel_h1: #777;
--axel_h1-bg: none;
--axel_h1-bottom: none;
--axel_h2: #27a;
--axel_h2-bg: #282828;
--axel_h2-bottom: 2px solid #256;
--axel_h2-hero-bottom: 2px solid #712;
--axel_h3: #838;
--axel_h3-bottom: 1px solid #333;
--axel_hero_bg: #242424;
--axel_nav-bg: #242424;
--axel_nav-buttomborder: #555;
--axel_pre-background: #bcc;
--axel-th-background: #203038;
--axel-article-nav-border-top: 0px dotted #234;
}
/* ---------- left side ---------- */
a.Brand::before { a.Brand::before {
background: rgb(255,0,51); background: var(--axel_brand-pre-background);
color: #fff; color: #fff;
font-family: arial; font-family: arial;
font-weight: bold; font-weight: bold;
padding: 0.5em 0.3em; padding: 0.5em 0.3em;
content: 'IML'; content: 'IML';
margin-right: 0.4em; margin-right: 0.4em;
float: left;
} }
body, *{color: var(--color);} a.Brand:hover::before {
body{background: var(--bg-body);} background: var(--axel_brand-pre-background-hover);
a{color: var(--link);}
a:hover{opacity: 0.7;}
h1>a{ color:var(--title);}
_h1:nth-child(1){position: fixed; background: var(--bg); box-shadow: 0 0 1em #ccc; padding: 0 1em}
h1:nth-child(1)>a{ color:var(--navlinkactive); }
.s-content h1{color: var(--h1); font-size: 200%; font-weight:bold; margin-top: 2em; border-bottom: var(--h1-bottom);}
.s-content h2{color: var(--h2); font-size: 160%; }
.s-content h3{color: var(--h3); font-size: 140%; }
.s-content h4{margin: 0; font-size: 100%; text-align: center; background-color: rgba(0,0,0,0.05);padding: 0.3em;}
.s-content pre{
background: var(--bg-pre);
} }
/* ---------- classes ---------- */ a.Brand {
background: var(--axel_brand-background);
.required{color:#a42;} font-size: 200%;
.optional{color:#888;} height: 4em;
}
/* ---------- page header: breadcrumb ---------- */
.Page__header {
border: none;
}
/* ----- top left */ .Page__header a {
.Brand, color: var(--axel_h1_header);
.Columns__left {
background: var(--bg);
border-right: 0px solid #e7e7e9;
color: var(--color);
} }
.Brand{font-size: 200%;
background_: linear-gradient(-10deg,#fff 50%, #ddd); .Page__header h1 {
background: var(--bg); font-size: 1.3em;
} }
.Columns__right__content {
background: var(--bg); /* ---------- page content ---------- */
.s-content {
padding-top: 1em;
} }
/* ----- Navi left */ .s-content h1 {
background: var(--axel_h1-bg);
color: var(--axel_h1);
font-size: 200%;
font-weight: bold;
margin-bottom: 2em;
margin-top: 2em;
border-bottom: var(--axel_h1-bottom);
}
.Nav a:hover{ .s-content h2 {
background: none; background: var(--axel_h2-bg);
color: var(--navlinkactive) !important; color: var(--axel_h2);
font-size: 180%;
font-weight: bold;
margin-top: 4em;
border-bottom: var(--axel_h2-bottom);
} }
.Nav__item--active { h1:first-of-type {
border-right_: 0.3em solid var(--navlinkactive); margin-top: 0em;
} }
.Nav__item--active > a{
background: var(--bg-navlinkactive); h2:first-of-type {
color: var(--navlinkactive); margin-top: 0em;
} }
.Nav .Nav .Nav__item--active a {
color: var(--navlinkactive); .s-content h3 {
background: var(--axel_h3-bg);
color: var(--axel_h3);
font-size: 150%;
font-weight: bold;
margin-top: 3em;
border-bottom: var(--axel_h3-bottom);
} }
.Nav .Nav .Nav__item a {
opacity: 1; .s-content h4 {
margin: 0;
font-size: 100%;
text-align: center;
background-color: rgba(0, 0, 0, 0.05);
padding: 0.3em;
} }
.Nav__item--open > a {
background-color: var(--bg); .s-content pre {
background: var(--axel_pre-background);
} }
.Nav a[href*="__Welcome"]{ /* FIX smaller fnt size in tables */
background: url("/icons/house.png") no-repeat 10px 4px ; .s-content table {
padding-left: 40px; font-size: 1em;
} }
.Nav a[href*="__How_does_it_work"]{
background: url("/icons/light-bulb.png") no-repeat 10px 4px ; .s-content table th {
padding-left: 40px; background: var(--axel-th-background);
} }
.s-content h3 code {
border: none;
background: none;
}
article nav {
border-top: var(--axel-article-nav-border-top);
margin: 8em 0 5em;
}
.Pager li>a {
padding: 1em 2em;
}
/* ---------- classes ---------- */ /* ---------- classes ---------- */
.required {
color: #a42;
}
/* FIX smaller fnt size in tables */ .optional {
.s-content table { color: #888;
font-size: 1em; }
div.hero {
background: var(--axel_hero_bg);
border-radius: 2em;
padding: 5em 2em;
text-align: center;
margin-bottom: 1.5em;
} }
div.hero h2 {
color: var(--color-text);
background: none;
border-bottom: var(--axel_h2-hero-bottom);
font-size: 300%;
margin: 0 0 2em;
}
/* TOC */ /* ---------- TOC ---------- */
@media(min-width:1700px){ @media(min-width:1700px) {
.TableOfContentsContainer{ .TableOfContentsContainer {
position: fixed; position: fixed;
right: 2em; right: 2em;
top: 1em; top: 1em;
} }
} }
.TableOfContentsContainer{ .TableOfContentsContainer {
border-top-left-radius: 1em; background-color: var(--axel_bg-toc);
background-color: var(--bg-toc); padding: 0.5em;
border-left: 2px solid rgba(0,0,0,0.05);
padding: 0em;
} }
.TableOfContentsContainer__content {
border: none; .s-content .TableOfContentsContainer h4 {
font-size: 0.5em; background-color: var(--axel_bg-toc-head);
border-top-left-radius: 1em;
font-size: 1.1em;
margin: 0;
padding: 0;
}
.TableOfContentsContainer__content {
border-width: 1px;
font-size: 0.5em;
} }
ul.TableOfContents ul{
list-style-type: none; ul.TableOfContents ul {
list-style-type: none;
padding-left: 1em; padding-left: 1em;
} }
.TableOfContentsContainer a{ color:var(--toclink);}
.TableOfContentsContainer__content > .TableOfContents > li + li { /* ----- Icons on links --- */
border-top: none;
}
.TableOfContentsContainer__content > .TableOfContents > li {
border-bottom: 1px dashed #ddd;
}
/* pager - prev .. next */ .EditOn a::before{
.s-content{ content: '✏️ ';
margin-bottom: 6em;
} }
.Pager{
border-top: 1px dashed #aaa; margin: 0; padding: 1em; .Links a[href^="https://github.com/"]::before {
content: '🌐 ';
} }
.Pager a{
color:var(--navlinkactive); .Links a[href^="https://git-repo.iml.unibe.ch/"]::before {
content: '🌐 ';
} }
.Links a[href^="https://os-docs.iml.unibe.ch"]::before {
content: '📗 ';
}
\ No newline at end of file
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment