Die Admin-Fabric

Automatisierung administrativer Aufgaben für Softwareentwickler
Kommentare

Die Automatisierung täglicher Arbeitsabläufe ist für Administratoren keine neue Aufgabe. Auch der geneigte Softwareentwickler wird schon das eine oder andere mal in die Verlegenheit gekommen sein, für einfache Aufgaben der täglichen Routine eine programmatische Lösung zu bauen – jedoch war die Notwendigkeit hierfür meist eher im lokalen Entwicklungsumfeld des Programmierers zu finden. Doch was, wenn es serverübergreifend wird?

In Zeiten, in denen die Grenzen zwischen Entwickler und Administrator fließend werden und Schlagworte wie DevOps die Runde machen, ist es für jeden Entwickler wichtig, Wege für die system- bzw. serverübergreifende Automatisierung von Aufgaben zu finden und zu verstehen, was für die meisten Administratoren ihr täglich Brot ist. Die Liste der Frameworks, die für genau diesen Zweck herangezogen werden können, ist lang und unübersichtlich. Bei der genauen Beschäftigung mit der Materie muss man enttäuscht feststellen: die eierlegende Wollmilchsau ist nicht dabei. Außerdem sind viele dieser Frameworks groß und mächtig und daher für den Entwickler, dessen Hauptaufgabe sicherlich das Entwickeln von Software bleiben soll, nur mit enormem Aufwand zu erlernen und zu warten.

Eines dieser Frameworks, das unserer Ansicht nach aus der Masse hervorgestochen ist, heißt Fabric. Es ist einfach zu erlernen und zu verstehen. Es ist kompakt, aber kein stumpfes Messer, und es ist ohne oder mit geringem zusätzlichem Aufwand auf fast jedem System einsetzbar. Es ist allerdings nicht in PHP, sondern Python geschrieben. Was dem einen Leser jetzt die Tränen in die Augen und in Gedanken schon dazu treibt, das Lesen dieses Artikels an genau dieser Stelle wegen Hochverrat abzubrechen, sieht der andere hoffentlich als Chance. Es ist schließlich die Möglichkeit, einen Blick über den Tellerrand zu werfen und dies gleich in doppelter Hinsicht: Beim Automatisieren von Arbeitsabläufen und das auch noch in einer „fremden“ Programmiersprache – wer bis an diese Stelle gekommen ist, der sei willkommen. Viel Spaß beim Lesen.

Die Vorstellung von Fabric überlasse ich der zugehörigen README-Datei, weil damit auch schon alles gesagt ist, was man an diesem Punkt wissen muss: „Fabric is a Python library and command-line tool for streamlining the use of SSH for application deployment or systems administration tasks.“

Die Reise in die Welt von Fabric beginnt – wie meist – mit der Installation. Dann werden wir uns über ein klassisches „Hello-World“-Beispiel zu einem realen Problem vortasten, um anschließend einen tieferen Blick in die Möglichkeiten und Funktionen zu riskieren.

Installation

Für die Installation von Fabric sind nur wenige Abhängigkeiten notwendig, und die meisten benötigten Pakete sind bei den bekannten Linux-Distributionen vorinstalliert. Benötigt wird:

Python • setuptools (eine Python-Library für Paketmanagement/Installation … wir nennen es PEAR) • Python SSH (eine SS2-Library für Python) • PyCrypto (Kryptografie-Library, die für SSH benötigt wird)

Wenn die Python setuptools installiert sind, erfolgt die Installation von Fabric mit einem einfachen Befehl: pip install fabric (oder pip install fabric==dev für den aktuellen dev Branch) oder, wenn eine alte Version der setuptools installiert ist, mit easy_install install fabric. Auf Debian-/Ubuntu-Systemen kann die Installation auch direkt mit apt-get erfolgen:

apt-get install fabric

Aufmacherbild: Worker welder container box maintenance in location von Shutterstock / Urheberrecht: KAMONRAT

[ header = Seite 2: Let´s start – Hello Fabric! ]

Let‘s start – Hello Fabric!

Das Basiskonzept ist einfach und mit wenigen Worten erläutert. Das Kommandozeilentool fab wird aufgerufen und führt in Abhängigkeit der übergebenen Kommandozeilenparameter definierte Abläufe aus. Diese werden in der Datei fabfile.py definiert, die im Idealfall direkt im Root-Verzeichnis des jeweiligen Projekts abgelegt wird. Um die grundlegende Logik zu veranschaulichen, hier das klassische „Hallo-Welt“-Beispiel:

def hello(name="fabric"):
  print "Hallo %s" % name)

Wie der geneigte PHP-Entwickler dem obigen Beispiel entnehmen kann, ist ein Semikolon in Python am Zeilenende nicht notwendig – auch wenn man als PHP-Entwickler diesen Reflex nur schwer unterdrücken kann. Das Ganze speichern wir in einer fabfile.py und rufen es über die Konsole auf:

$ fab hello

Oder, wenn ein anderer Parameter übergeben werden soll, mit:

$ fab hello:name=steffen

Zum Ausführen von Shell-Befehlen innerhalb des Skripts unterscheidet Fabric allgemein zwischen lokalen und remote-Befehlen. Für lokale Befehle benötigen wir den entsprechenden import from fabric.api import local und anschließend die local-Funktion.

from fabric.api import local
def lokaleAufgabe():
  local("./run_some_script")
  local("uptime")

Für das Ausführen von Befehlen auf Remote-Systemen sieht das ganz identisch aus. Lediglich das import-Statement muss erweitert und die auszuführenden Befehle müssen an die run()-Funktion übergeben werden.

from fabric.api import local, run
def remoteAufgabe():
  run("uptime")

Bei der Ausführung des letzten Skripts mit fab remoteAufgabe fragt Fabric während der Ausführung nach einem Connection String für den Zielhost. Um das zu umgehen, können die Verbindungsdaten auch direkt innerhalb des fabfile abgelegt werden. Das erfolgt im Idealfall nicht direkt in der jeweiligen Funktion – was aber auch einen legitimen Weg darstellt – sondern gekapselt in eigenen Funktionen, um die Widerverwertbarkeit des Codes zu optimieren.

def sever():
  env.user = 'steffen'
  env.hosts = ['server.steffen.dev']

Bei env.hosts handelt es sich um ein Array bzw. eine List, in der direkt mehrere Server übergeben werden können, z. B. env.hosts = [’server1.steffen.dev‘, ’server2.steffen.dev‘]. Für einen einzelnen Host kann alternativ auch env.host verwendet werden. Um die gesetzten Parameter in unserer Funktion remoteAufgabe() verwenden zu können, müssen wir vorher die server()-Funktion aufrufen. Das geschieht über die Kommandozeile mit fab server remoteAufgabe.

Es handelt sich bei der Authentifizierung um eine SSH-Verbindung, d. h. es wird entweder beim Aufbau der Verbindung nach einem Passwort gefragt oder es sind die entsprechenden Keys im jeweiligen .ssh/-Verzeichnis des Benutzers abgelegt. Alternativ kann auch mit dem Kommandozeilenparameter „-i“ ein spezieller private-key übergeben oder mit dem Parameter „-k“ die Umgehung lokaler Keys erzwungen werden. Auch eine explizite Übergabe des Passworts kann mit „-p“ erfolgen.

Ein Blick in die Kommandozeilenparameter kann sich gerade bei kleinen Skripten lohnen, da hier einiges an Skriptarbeit abgefangen werden kann. Die Wichtigsten sind in Tabelle 1 aufgeführt.

Parameter

Funktion

fab -l

Übersicht aller definierten Funktionen innerhalb der fabfile.py

fab -c

Übergabe einer fabfile in einem anderen Pfad

fab D

Deaktiviert das laden der known_host-Datei

fab -H

Kommaseparierte Liste an Host für die Ausführung

fab -i

Pfad zu einem privaten SSH-Key zur Verwendung

fab -k

Ignoriert die Keys aus .ssh/

fab –linewise

Stellt die Ausgabe von Bytewise auf Linewise

fab –port

SSH Port für die Verbindung

fab –skip-bad-hosts

Überspringen nicht erreichbare Hosts bei der Ausführung

fab -t

Benutzerdefinierten Timeout setzen

fab -u

Username für den Verbindungsaufbau

fab -w

Setzt warn_only auf „true“, siehe Abschnitt Fehlerbehandlung

fab -x

Exkludiert die angegeben Hosts

Tabelle 1: Die wichtigsten und nützlichsten Parameter für „fab“

[ header = Seite 3: Willkommen in der realen Welt – ein Beispiel ]

Willkommen in der realen Welt – ein Beispiel

Mit diesen einfachen Grundlagen haben wir jetzt alles, was wir brauchen, um einen einfachen Releasevorgang mittels Fabric zu automatisieren. Das Diagramm in Abbildung 1 zeigt das Setup, das umgesetzt werden soll.

Abb. 1: Das Beispielsetup für unser Releaseskript

Der Arbeitsablauf sieht wie folgt aus:

Aktualisierung des Testing-Systems via VCS (in diesem Beispiel Subversion) • Erzeugen eines Release-Bundles auf dem Testing-System • Update des Staging-Servers mit dem vorher erzeugten Bundle • Update des Liveservers mit dem auf Staging getesteten Bundle • Synchronisierung des Livesystems auf den zugehörigen Cold-Standby-Server

Als Erstes beginnen wir, unsere fabfile mit den notwendigen Importen auszurüsten:

from __future__ import with_statement
from fabric.api import * 

Die erste import-Anweisung im letzten Beispiel ist in Python eine Möglichkeit, um zukünftige Funktionen auch in älteren Python-Versionen zu nutzen. In diesem Fall die with-Anweisung; sie wird erst in Python Version 2.6 zum Sprachumfang hinzugefügt und kann über den __future__-Namespace aber auch schon in Python 2.5 verwendet werden. Die Wildcard im zweiten import-Statement ermöglicht uns die Nutzung aller Funktionalität des entsprechenden Namespaces, ohne uns weiter Gedanken über notwendige Importe machen zu müssen.

Anschließend werden die entsprechenden Connection-Informationen in einzelnen Funktionen gekapselt, um die geplanten Tasks universell je nach übergebenem Server ausführen zu können.

def testing():
  env.user = 'teststeffen'
  env.hosts = ['test.steffen.dev']

def staging():
  env.user = 'stagesteffen'
  env.hosts = ['stage.steffen.dev']

...

Die Funktion für die Aktualisierung des Testsystems über ein VCS (in diesem Beispiel SVN) ist einfach und schnell zu implementieren. Um weitere Möglichkeiten von Fabric zu entdecken, nutzen wir hier das eingangs erwähnte with-Statement und eine neue Funktion cd() zum schnellen Setzen eines Arbeitsverzeichnisses.

def updateFromSvn():
  workingDir = '/home/steffen/working/'
  with cd(working_dir):
    run("svn commit")
    run("svn up") 

Der Aufruf kann nun mit fab testing updateFromSvn erfolgen, wahlweise kann als Parameter auch jeder andere vorher definierte Server übergeben werden. Da es meist sinnvoll ist, nach einem Update den Apache/MySQL-Server neu zu laden, bauen wir uns für diesen Zweck auch noch eine Funktion, die wir identisch für MySQL verwenden können.

def apacheCtl(param="status"):
  run("/etc/init.d/apache2 %s" % param) 

Um die Funktion nicht bei jedem Aufruf auf der Kommandozeile übergeben zu müssen, führen wir sie einfach mit apacheCtl(„restart“) am Ende der Funktion updateFromSvn() aus.

Im nächsten Schritt wollen wir nach dem Abschluss des Deploys auf unser Testsystem mit den aktuellen Daten aus dem SVN ein lokales Archiv erzeugen, das dann für das Staging bzw. Produktivrelease genutzt werden soll.

def createReleaseTar():
  svnPath = "svn://myfirstsvnserver.com/mysoftware“
  local("svn checkout %s" % svnPath)
  local("tar czvf /tmp/latest.tgz mysoftware")

Soweit nichts Neues: Wir holen die Daten aus unserem VCS und erzeugen ein tar-Archiv aus dem Verzeichnis. Nun müssen wir es für das eigentliche Release auf die entsprechenden Server verteilen. Hierfür bietet Fabric auch wieder eine fertige Funktion put(). Beziehungsweise, wenn man Daten von einem Server holen will, das Pedant get(). Damit können wir für die Verteilung der Software auf die Server die folgenden Funktionen verwenden:

def copyReleaseTar():
  put("/tmp/latest.tgz", "/tmp/latest.tgz") 

Als zusätzliche Parameter kann der put()-Funktion mit mode=xxxx eine Berechtigung übergeben werden.

Die weiteren Schritte können wir dann leicht aus dem bisher erworbenen Wissen ableiten. Es wird lediglich noch eine Methode zum Extrahieren auf dem jeweiligen Zielsystem benötigt und ggf. eine Methode, um ein bestehendes Release vorher zu sichern.

def makeRelease():
  releasePath = "/srv/www/mysoftware"
  with cd(releasePath):
    run("mv release/ backup/")
    run("tar xzf /tmp/latest.tgz")
  apacheCtl("restart")

Die Synchronisierung auf den Standby-Server lässt sich nun je nach Gusto entweder mit einem gesonderten Aufruf der copyReleaseTar()- und makeRelease()-Funktionen erreichen, oder durch eine eigene Funktion, welche die Daten kopiert und anschließend extrahiert.

Damit haben wir unser Releaseskript nun komplett und können die Releases mit einfachen Befehlen automatisch ausführen. Das vollständige Skript finden Sie in Listing 1.

$ fab testing updateFromSvn
$ fab createReleaseTar
$ fab staging copyReleaseTar makeRelease
$ fab production copyReleaseTar makeRelease
$ fab standby copyReleaseTar makeRelease

Eine vollständige Liste aller möglichen Befehle und Funktionen einer fabfile erhält man durch den Aufruf von fab-list.

from __future__ import with_statement
from fabric.api import *

def testing():
  env.user = 'teststeffen'
  env.hosts = ['test.steffen.dev']

def staging():
  env.user = 'stagesteffen'
  env.hosts = ['stage.steffen.dev']

def production():
  env.user = 'productionsteffen'
  env.hosts = ['production.steffen.dev']

def standby():
  env.user = 'standbysteffen'
  env.hosts = ['standby.steffen.dev']

def updateFromSvn():
  workingDir = '/home/steffen/working/'
  with cd(working_dir):
    run("svn commit")
    run("svn up")

def createReleaseTar():
  svnPath = "http://myownsvnserver.dev/mysoftware"
  local("svn checkout %s" % svnPath)
  local("tar czvf /tmp/latest.tgz mysoftware")

def copyReleaseTar():
  put("/tmp/latest.tgz", "/tmp/latest.tgz")

def makeRelease():
  releasePath = "/srv/www/mysoftware"
  with cd(releasePath):
    run("mv release/ backup/")
    run("tar xzf /tmp/latest.tgz")
  apacheCtl("restart")

def apacheCtl(param="status"):
  run("/etc/init.d/apache2 %s" % param) 

[ header = Seite 4: Erweiterte Ablaufsteuerung ]

Erweiterte Ablaufsteuerung

Der letzte Abschnitt hat die Grundfunktionalität abgedeckt und ermöglicht das Erstellen kleiner Skripts zur Automatisierung administrativer Abläufe – in unserem Beispiel eines Release. Doch das ist nur die Spitze des Eisbergs; einige erweiterte Funktionen sollen nun erläutert werden.

Fabric bietet seit Version 1.3 die Möglichkeit, Funktionen abhängig von Rollen automatisch in einer definierten Reihenfolge auszuführen. Hierzu müssen zuerst die roles definiert werden. Diese Definition erfolgt in einer env-Variable.

env.roledefs = {
  'developer' : ['devel', 'testing'],
  'www' : ['production'], ['standby'],
}

Anschließend werden die Funktionen den jeweiligen Rollen mit @roles(‚developer‘) zugewiesen.

@roles('developer')
def updateFromSvn():
  ...

Durch die definierten Rollen ist es dann möglich, die Arbeitsabläufe in einer zentralen Funktion zu bündeln. In unserem Beispiel würden wir hierzu eine neue Funktion deployAll() erstellen und in ihr den Ablauf unserer verschiedenen Arbeitsschritte abbilden.

def deployAll():
  execute(updateFromSvn)
  execute(…)

Die Aufgaben werden nacheinander entsprechend der zugewiesenen Rollen abgearbeitet, und ein einziger Aufruf von fab deployAll führt dann zu einem Update des kompletten Setups.

Eine definierte Liste von Aufgaben wird von Fabric immer in der definierten Reihenfolge abgearbeitet, standardmäßig gilt auch das so genannte Fast-Fail-Verhalten des Skripts, d. h. wenn ein extern aufgerufenes Programm einen Fehler ausgibt oder eine Python-Exception während der Ausführung geworfen wird, bricht das Skript umgehend ab. Im nächsten Abschnitt wird nochmals näher erläutert, wie man dieses Verhalten ändern kann.

Fehlerbehandlung

Ein bislang nicht beleuchteter, aber wichtiger Aspekt bei der Entwicklung: Die Fehlerbehandlung. Es gibt, ähnlich wie das Error Reporting bei PHP, in Fabric eine globale Variable, die das Verhalten eines Skripts bei Auftreten eines Fehlers festlegt – die boolesche Variable warn_only. Diese ist per Default auf „false“ gesetzt und kann entweder global über env.warn_only = true oder während der Laufzeit, d. h. für einzelne Abschnitte, mit with settings(warn_only=true) verwendet werden. Im zweiten Fall gilt das gesetzte Verhalten nur für den jeweiligen Codeabschnitt innerhalb der with-Struktur. Ein einfaches Beispiel für die Fehlerbehandlung kann wie folgt aufgebaut werden:

def mitFehler():
  with settings(warn_only=true):
    result = local('./meinskript.sh', capture=true)
  if result.failed
    abort("Da läuft was schief!")

Eine Besonderheit in diesem Beispiel ist, dass bei der Funktion local zusätzlich der Parameter capture=true gesetzt wird. Dieser ist standardmäßig „false“, was bedeutet, dass alle stdout– und stderr-Streams direkt im Terminal ausgegeben bzw. abgefangen werden. Durch ein Setzen auf „true“ wird stdout in ein Objekt geschrieben. Es können die Werte return_code, stderr, failed und succeeded abgefragt werden. Zusätzlich wird in dem Beispiel das Error Handling nicht global, sondern nur innerhalb dieser Funktion geändert. Hierzu wird der settings-Kontext verwendet, der ggf. mit einem from fabric.api import settings geladen werden muss. Die abort-Funktion innerhalb der if-Bedingung führt zu einem sofortigen Programmabbruch, es wird eine Nachricht nach stdout geschrieben und ein Error-Status gesetzt.

Dieses Beispiel lässt sich auch nicht so einfach für die Behandlung von Fehlern auf Remote-Systemen adaptieren, da sich der local-Befehl hier vom run-Befehl unterscheidet. Bei der Remote-Ausführung werden immer die Standard-Output-Buffer stdout und stderr verwendet. Im Allgemeinen unterscheidet Fabric zwischen folgenden Output-Levels:

  • status: Allgemeine Statusmeldungen des Skripts, z. B. Disconnect-Nachricht
  • aborts: Abbruchhinweise, sollten nicht deaktiviert werden
  • warnings: Allgemeine Warnhinweise, z. B. wenn ein Remote-Programm einen Fehler ausgibt
  • running: Anzeige der Befehle, die remote oder lokal ausgeführt werden
  • stdout: Lokaler oder Remote stdout-Buffer
  • stderr: Lokaler oder Remote stderr-Buffer
  • user: Selbstdefinierte Ausgaben
  • debug: Debug-Meldungen

Zusätzlich bietet Fabric auch noch Gruppierungen/Aliases für die Kombination der unterschiedlichen Output-Levels an:

  • output: Kombiniert stdout und stderr
  • everything: Gibt alles aus, vergleichbar mit E_ALL in PHP
  • commands: Kombiniert stdout und running, stderr wird auch mit ausgegeben

Diese Optionen können, wie meistens in Fabric, auf verschiedene Weise gesteuert werden: programmatisch mit fabric.state.output oder über die Kommandozeile mit —hide bzw. —show gesetzt werden.

[ header = Seite 5: Parallelwelten ]

Parallelwelten

Die Ausführung von Fabric-Skripten erfolgt per Default immer sequenziell, also „der Reihe nach“. Das ist für die meisten Anwendungszwecke auch vollkommen ausreichend. Soll jedoch z. B. ein Cluster an Applikationsservern zeitgleich aktualisiert oder ein Arbeitsschritt auf mehreren Systemen parallel ausgeführt werden, können wir das in der fabfile vorher explizit definieren.

Da Fabric von Haus aus nicht Thread-safe arbeitet, wird für diesen Zweck das Python-Multiprocessing-Modul verwendet, das bei Python ab Version 2.5 oder höher standardmäßig installiert ist. Setzten sie eine ältere Version ein, sollten sie am besten die Version updaten oder das Modul gesondert nachinstallieren. Durch die Kombination mit diesem Modul wird für jede Host/Aufgaben-Kombination ein gesonderter Prozess gestartet.

Um das Thema etwas zu veranschaulichen, erweitern wir die makeRelease()-Funktion aus unserem Beispiel. Nehmen wir an, wir haben nicht nur einen Web- bzw. Applikationsserver (Abb. 1), sondern ein Cluster aus diesen Systemen, die alle aktualisiert werden sollen. Hierzu hätten wir zwei Möglichkeiten: Entweder übergeben wir sie via Kommandozeile mit

$ fab production1 production2 productionX copyReleaseTar makeRelease

oder wir erzeugen eine roledef, in der wir die verschiedenen Systeme definieren. Beide Varianten führen dazu, dass die Funktionen je nach System und Aufgabe der Reihe nach ausgeführt werden. In unserem Fall wird erst die Funktion copyReleaseTar() auf Server 1 – X ausgeführt, anschließend die makeRelease()-Funktion.

Abbildung 2: Zeitlicher Vorteil bei paralleler Ausführung

Eine parallele Ausführung von Aufgaben zu erzwingen, kann mit dem Kommandozeilenparameter „-P“ oder dem Setzen von env.parallel=true erreicht werden. Oft ist das jedoch nicht das ideale oder gewünscht Verhalten, da nicht zwingend alle Aufgaben parallel ausgeführt werden sollen. Die parallele Ausführung einzelner Funktionen kann über den @parallel-Decorator gesteuert werden. Er wird vor der Funktion in den Code gesetzt und führt dazu, dass die so bestimmte Funktion parallel ausgeführt wird.

@parallel
def copyReleaseTar():
  …

Bei der Verwendung der eingangs erläuterten Varianten (Kommandozeile, env-Variable) kann auch mit dem Decorator @serial gearbeitet werden, der die serielle Ausführung einer Funktion erzwingt. Er ist jedoch nur notwendig, wenn die parallele Ausführung auf das gesamte Skript erzwungen wird.

@serial
def copyReleaseTar():
  …

Um zu vermeiden, dass die Maschine, auf der das Fabric-Skript ausgeführt wird, zu viele parallele Prozesse startet, kann die Anzahl der Prozesse über die pool_size eingeschränkt werden. Diese Einschränkung kann wieder direkt global über die Kommandozeile mit -z gesetzt oder auch je nach Funktion direkt im Code definiert werden.

@parallel(pool_size=5)
def makeRelease():
  ...

Da Fabric in der Standardkonfiguration seine Daten immer byteweise auf dem Terminal ausgibt, kann dies zu Verwirrung bei der parallelen Ausführung führen. Deswegen schaltet Fabric in diesem Fall immer auf zeilenweise Ausgabe um. Das ändert zwar nichts an der Tatsache, dass die Ausgabe in diesem Fall etwas unübersichtlicher wird als gewohnt, jedoch ist eine Zuordnung der Ausgaben dank der Host-Bezeichnung in jeder neuen Zeile relativ einfach möglich.

Fazit

Erst einmal vielen Dank, dass Sie mit mir den Blick über den Tellerrand gewagt haben. Hoffentlich konnten die Vorzüge von Fabric auf den letzten Seiten deutlich gemacht werden. Sicherlich kann der Artikel nur einen Abriss über die Funktionalität geben, aber dank der guten und sehr aktuellen Dokumentation auf der Homepage des Projekts und den sehr interessanten Beispielen aus der Praxis (eine einfache Google-Suchanfrage nach „fabfile.py“ kann hier echte Schätze zu Tage fördern) kann man leicht tiefer in die Materie eintauchen und administrative Aufgaben mit der Einfachheit von Shell-Skripten und der Flexibilität einer komplexen Programmiersprache lösen. Denn – auf was bis jetzt noch nicht eingegangen wurde, was aber sicherlich den Rahmen des Artikels sprengen würde – Fabric kann auch als Library direkt in eigene Python-Skripte eingebunden und die komplette Funktionalität von Python kann in Fabric-Skripten genutzt werden. Mit dieser Funktionalität im Rücken braucht sich Fabric dann definitiv nicht mehr hinter all den anderen Lösungen zu verstecken.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -