Klaster OKD 4.19 (OpenShift) – Projekt s3store oparty na MinIO – s3store-gui

Informacje wstępne o projekcie s3store

Opis projektu s3store obejmuje 2 wpisy na tym blogu:

Opis rozwiązania MinIO

MinIO to lekka i „budżetowa” obiektowa pamięć masowa, w 100% zgodna z API Amazon S3 (Simple Storage Service). Najlepiej sprawdza się do trzymania danych nieustrukturyzowanych, czyli np. zdjęć, filmów, logów, plików eksportów czy różnych „artefaktów” z systemów.

Jest uniwersalne, bo możesz je wykorzystać m.in. do:

  • archiwizacji danych,
  • składowania i analizy dużych zbiorów danych,
  • backupów oraz odtwarzania awaryjnego (DR).

MinIO pozwala też łatwo zbudować klaster z wielu mniejszych instancji storage’u (tzw. podejście „microstorage” – dużo małych elementów zamiast jednego dużego). Dzięki temu rozwiązanie jest proste do skalowania (dokładasz kolejne nody) i potrafi zapewnić wysoką dostępność, bo awaria pojedynczej instancji nie musi zatrzymać całej usługi.

S3 (i MinIO jako „S3-compatible”) nie jest filesystemem, tylko API do obiektów:

  • Bucket = logiczny „kontener”
  • Object = plik + metadane, identyfikowany przez klucz (np. uploads/mors.1.0.0_openshift.tar.gz)

Nie ma „montowania S3 jako PVC” w naturalny sposób. Więcej informacji:

Założenia projektu s3store

Projekt s3store składa się z 2 aplikacji:
1. Aplikacja s3store-manager, która obsługuje funkcjonalność

  • MinIO Server (S3 API)
  • MinIO Console (admin)

i zawiera elementy

  • Secret z root credentials (admin) MinIO (np. MINIO_ROOT_USER, MINIO_ROOT_PASSWORD)
  • Deployment
  • Service port 9000 → S3 API, port 9001 → MinIO Console
  • Route(y): route do Console (żeby adminowo klikać z przeglądarki). route do S3 API (jeśli chcesz upload bezpośrednio z laptopa / presigned URLs)

MinIO ma wbudowaną konsolę web i standardowo wystawia ją na osobnym porcie.

ewentualnie Job „init” do bucket/user/policy

2. Aplikacja s3store-gui, który obsługuje funkcjonalność

  • PHP: upload + listing + download link
  • PHP odbiera upload
  • PHP robi putObject() do MinIO przez S3 API
  • PHP pokazuje link/listę obiektów w bucket

Struktura aplikacji s3store-gui w projekcie s3store-prod

/repo/pakiety/s3store/1.0.0/s3store-gui
-rw-r--r-- 1 bastuser bastuser   554 Feb 14 15:06 cm-s3store-gui-s3.yaml
-rw-r--r-- 1 bastuser bastuser   888 Feb 14 15:06 deploy-s3store-gui.yaml
-rw-r--r-- 1 bastuser bastuser   775 Feb 14 15:06 Dockerfile
-rw-r--r-- 1 bastuser bastuser 11215 Feb 14 15:05 index.php
-rw-r--r-- 1 bastuser bastuser   190 Feb 14 15:07 svc-s3store-gui.yaml

Przygotowanie Dockerfile na potrzeby zbudowania obrazu aplikacji

FROM registry.access.redhat.com/ubi9/php-82:latest

USER 0

RUN printf "upload_max_filesize=100M\npost_max_size=110M\nmemory_limit=256M\nmax_execution_time=300\n" > /etc/php.d/zz-upload.ini

WORKDIR /opt/app-root/src
COPY index.php /opt/app-root/src/index.php

# Composer + AWS SDK:
# 1) require bez --no-dev
# 2) install bez devów
RUN php -r "copy('https://getcomposer.org/installer', '/tmp/composer-setup.php');" \
 && php /tmp/composer-setup.php --install-dir=/usr/local/bin --filename=composer \
 && rm -f /tmp/composer-setup.php \
 && composer --no-interaction --working-dir=/opt/app-root/src require aws/aws-sdk-php:^3 \
 && composer --no-interaction --working-dir=/opt/app-root/src install --no-dev --prefer-dist --no-progress --optimize-autoloader

RUN chgrp -R 0 /opt/app-root/src /etc/php.d \
 && chmod -R g=u /opt/app-root/src /etc/php.d

USER 1001
EXPOSE 8080
CMD ["/usr/libexec/s2i/run"]

ConfigMap YAML z danymi dostępowymi dla aplikacji s3store-gui

apiVersion: v1
kind: ConfigMap
metadata:
  name: s3store-gui-s3
  namespace: s3store-prod
data:
  # najlepsze dla aplikacji działającej W KLASTRZE:
  S3_ENDPOINT: "http://s3store-manager.s3store-prod.svc:9000"
  S3_BUCKET: "uploads"
  AWS_REGION: "homelab"

  AWS_ACCESS_KEY_ID: "s3store-gui-user"
  AWS_SECRET_ACCESS_KEY: "cGW8oKh0mleGaNKvWHecLCApaDYj+w2S"

  # MinIO + własny endpoint -> path-style najbezpieczniej:
  S3_PATH_STYLE: "true"

  # endpoint jest HTTP, więc verify SSL nie ma znaczenia, ale zostawiamy spójnie:
  S3_VERIFY_SSL: "true"

Deployment aplikacji s3store-manager w projekcie s3store-prod

apiVersion: apps/v1
kind: Deployment
metadata:
  name: s3store-manager
  namespace: s3store-prod
spec:
  replicas: 1
  selector:
    matchLabels:
      app: s3store-manager
  template:
    metadata:
      labels:
        app: s3store-manager
    spec:
      containers:
        - name: minio
          image: image-registry.openshift-image-registry.svc:5000/s3store-prod/s3store-manager:latest
          imagePullPolicy: Always
          envFrom:
            - secretRef:
                name: s3store-minio-root
            - configMapRef:
                name: s3store-minio-config
          ports:
            - name: s3api
              containerPort: 9000
            - name: console
              containerPort: 9001
          volumeMounts:
            - name: data
              mountPath: /data
          readinessProbe:
            httpGet:
              path: /minio/health/ready
              port: 9000
            initialDelaySeconds: 10
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /minio/health/live
              port: 9000
            initialDelaySeconds: 20
            periodSeconds: 20
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: s3store-manager-data

Service dla s3store-gui

apiVersion: v1
kind: Service
metadata:
  name: s3store-gui
  namespace: s3store-prod
spec:
  selector:
    app: s3store-gui
  ports:
    - name: http
      port: 8080
      targetPort: 8080

Utworzenie policy dla aplikacji s3store-gui (tylko bucket uploads)

Więcej informacji https://itadmin.vblog.ovh/klaster-okd-4-19-openshift-projekt-s3store-oparty-na-minio-s3store-manager/#Utworzenie_policy_dla_aplikacji_s3store-gui_tylko_bucket_uploads

Przygotowanie aplikacji s3store-gui w projekcie s3store-prod

cd /repo/pakiety/s3store/1.0.0/s3store-gui
oc project s3store-prod

# ConfigMap
oc apply -f cm-s3store-gui-s3.yaml

# utwórz BuildConfig typu docker, build z lokalnego katalogu
oc new-build --name=s3store-gui --binary --strategy=docker -n s3store-prod

# wystartuj build, biorąc Dockerfile z tego katalogu
oc start-build s3store-gui --from-dir=. --follow -n s3store-prod

# Deploy + Service
oc apply -f deploy-s3store-gui.yaml
oc apply -f svc-s3store-gui.yaml

# Route do aplikacji (Bootstrap UI)
oc delete route s3store-gui -n s3store-prod --ignore-not-found
oc create route edge s3store-gui --service=s3store-gui --port=http -n s3store-prod
oc get route s3store-gui -n s3store-prod

# restart aplikacji
oc rollout restart deployment/s3store-gui

Kod aplikacji PHP s3store-gui

# po zmianach w kodzie aplikacji
oc start-build s3store-gui --from-dir=. --follow
oc rollout restart deploy/s3store-gui -n s3store-prod
oc rollout status deploy/s3store-gui -n s3store-prod
<?php
declare(strict_types=1);

require __DIR__ . '/vendor/autoload.php';

use Aws\S3\S3Client;

function envv(string $k, ?string $default=null): string {
    $v = getenv($k);
    return ($v === false || $v === '') ? ($default ?? '') : $v;
}

function s3client(): S3Client {
    $endpoint = envv('S3_ENDPOINT');
    $region   = envv('AWS_REGION', 'homelab');
    $verifySsl = strtolower(envv('S3_VERIFY_SSL', 'true')) === 'true';
    $pathStyle = strtolower(envv('S3_PATH_STYLE', 'true')) === 'true';

    $opts = [
        'version' => 'latest',
        'region'  => $region,
        'endpoint' => $endpoint,
        'credentials' => [
            'key'    => envv('AWS_ACCESS_KEY_ID'),
            'secret' => envv('AWS_SECRET_ACCESS_KEY'),
        ],
        'use_path_style_endpoint' => $pathStyle,
        'http' => [
            'verify' => $verifySsl,
            'connect_timeout' => 5,
            'timeout' => 300,
        ],
    ];

    return new S3Client($opts);
}

function humanBytes(int $bytes): string {
    $units = ['B','KB','MB','GB','TB'];
    $i = 0;
    $v = (float)$bytes;
    while ($v >= 1024 && $i < count($units)-1) { $v /= 1024; $i++; }
    return sprintf(($i === 0 ? '%.0f %s' : '%.2f %s'), $v, $units[$i]);
}

function parseArchiveName(string $filename): array {
    // Oczekiwany format: s3storemanager_1.0.0_coo_openshift.tar.gz
    $re = '/^([a-zA-Z0-9-]+)_([0-9]+\.[0-9]+\.[0-9]+)_([a-zA-Z0-9-]+)_([a-zA-Z0-9-]+)\.tar\.gz$/';
    if (!preg_match($re, $filename, $m)) {
        return [
            'ok' => false,
            'nazwa' => '',
            'wersja' => '',
            'warstwa' => '',
            'token' => '',
        ];
    }
    return [
        'ok' => true,
        'nazwa' => $m[1],
        'wersja' => $m[2],
        'token' => $m[3],    // np. "coo" (nie pokazujemy w tabeli, ale możesz tagować jeśli chcesz)
        'warstwa' => $m[4],  // np. "openshift"
    ];
}

function sanitizeTagValue(string $v): string {
    $v = trim($v);
    // tag values w S3 nie lubią śmieci; upraszczamy:
    $v = preg_replace('/[^\pL\pN \(\)\-_.]/u', '_', $v) ?? $v;
    // ogranicz długość (konserwatywnie)
    return mb_substr($v, 0, 200);
}

$bucket = envv('S3_BUCKET', 'uploads');
$s3 = s3client();

$authors = [
    'Jan Kowalski (Developer)',
    'Adam Nowak (Administrator)',
];
$envs = ['PROD', 'DEV'];

$msg = null;
$msgType = 'info';

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    $author = $_POST['author'] ?? '';
    $envSel = $_POST['environment'] ?? '';

    if (!in_array($author, $authors, true)) {
        $msg = 'Nieprawidłowa wartość pola Autor.';
        $msgType = 'danger';
    } elseif (!in_array($envSel, $envs, true)) {
        $msg = 'Nieprawidłowa wartość pola Środowisko.';
        $msgType = 'danger';
    } elseif (!isset($_FILES['file'])) {
        $msg = 'Brak pliku w żądaniu.';
        $msgType = 'danger';
    } elseif ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
        $msg = 'Błąd uploadu do PHP (kod: ' . (int)$_FILES['file']['error'] . ').';
        $msgType = 'danger';
    } else {
        $maxBytes = 100 * 1024 * 1024; // 100MB
        $size = (int)$_FILES['file']['size'];
        $origName = (string)$_FILES['file']['name'];
        $filename = basename($origName);

        if ($size <= 0 || $size > $maxBytes) {
            $msg = 'Plik musi mieć max 100MB.';
            $msgType = 'danger';
        } elseif (!str_ends_with($filename, '.tar.gz')) {
            $msg = 'Dozwolone są tylko pliki .tar.gz';
            $msgType = 'danger';
        } else {
            $parsed = parseArchiveName($filename);
            if (!$parsed['ok']) {
                $msg = 'Nazwa pliku musi mieć format: nazwa_1.0.0_coo_openshift.tar.gz (np. s3storemanager_1.0.0_coo_openshift.tar.gz)';
                $msgType = 'danger';
            } else {
                // dodatkowa walidacja MIME (miękka)
                $tmpPath = (string)$_FILES['file']['tmp_name'];
                $finfo = new finfo(FILEINFO_MIME_TYPE);
                $mime = $finfo->file($tmpPath) ?: 'application/octet-stream';
                $allowedMimes = ['application/gzip', 'application/x-gzip', 'application/octet-stream'];
                if (!in_array($mime, $allowedMimes, true)) {
                    $msg = 'Wygląda na to, że to nie jest gzip (MIME: ' . $mime . ').';
                    $msgType = 'danger';
                } else {
                    // TAGI wymagane przez Ciebie:
                    $tags = [
                        'nazwa' => sanitizeTagValue($parsed['nazwa']),
                        'wersja' => sanitizeTagValue($parsed['wersja']),
                        'warstwa' => sanitizeTagValue($parsed['warstwa']),
                        'archiwum' => sanitizeTagValue($filename),
                        'autor' => sanitizeTagValue($author),
                        'srodowisko' => sanitizeTagValue($envSel),
                    ];

                    // AWS SDK przyjmuje Tagging jako querystring "k=v&k2=v2"
                    $tagging = http_build_query($tags, '', '&', PHP_QUERY_RFC3986);

                    try {
                        $s3->putObject([
                            'Bucket' => $bucket,
                            'Key'    => $filename,                 // obiekt w bucketcie uploads
                            'Body'   => fopen($tmpPath, 'rb'),
                            'ContentType' => 'application/gzip',
                            'Tagging' => $tagging,
                        ]);

                        $msg = 'Wgrano: ' . $filename . ' (' . humanBytes($size) . ') + zapisano tagi.';
                        $msgType = 'success';
                    } catch (Throwable $e) {
                        $msg = 'Błąd S3 putObject(): ' . $e->getMessage();
                        $msgType = 'danger';
                    }
                }
            }
        }
    }
}

// LISTA OBIEKTÓW
$rows = [];
$listError = null;

try {
    $res = $s3->listObjectsV2([
        'Bucket' => $bucket,
        'MaxKeys' => 200,
    ]);
    $objects = $res['Contents'] ?? [];

    foreach ($objects as $o) {
        $key = (string)$o['Key'];

        // interesują nas tylko .tar.gz (dla porządku)
        if (!str_ends_with($key, '.tar.gz')) {
            continue;
        }

        $parsed = parseArchiveName($key);

        // pobierz tagi (autor/środowisko i reszta)
        $tagsMap = [];
        try {
            $tres = $s3->getObjectTagging([
                'Bucket' => $bucket,
                'Key' => $key,
            ]);
            foreach (($tres['TagSet'] ?? []) as $t) {
                $tagsMap[(string)$t['Key']] = (string)$t['Value'];
            }
        } catch (Throwable $e) {
            // jeśli brak uprawnień do tagów, tabela nadal pokaże dane z nazwy pliku
            $tagsMap = [];
        }

        $rows[] = [
            'nazwa' => $tagsMap['nazwa'] ?? ($parsed['ok'] ? $parsed['nazwa'] : ''),
            'wersja' => $tagsMap['wersja'] ?? ($parsed['ok'] ? $parsed['wersja'] : ''),
            'warstwa' => $tagsMap['warstwa'] ?? ($parsed['ok'] ? $parsed['warstwa'] : ''),
            'archiwum' => $tagsMap['archiwum'] ?? $key,
            'autor' => $tagsMap['autor'] ?? '',
            'srodowisko' => $tagsMap['srodowisko'] ?? '',
        ];
    }
} catch (Throwable $e) {
    $listError = $e->getMessage();
}

?>
<!doctype html>
<html lang="pl">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
  <title>s3store-gui</title>
</head>
<body class="container py-4">

  <div class="d-flex align-items-center justify-content-between mb-3">
    <h1 class="h3 m-0">s3store-gui</h1>
    <div class="text-muted small">
      Bucket: <code><?= htmlspecialchars($bucket) ?></code>
      Endpoint: <code><?= htmlspecialchars(envv('S3_ENDPOINT')) ?></code>
    </div>
  </div>

  <?php if ($msg): ?>
    <div class="alert alert-<?= htmlspecialchars($msgType) ?>"><?= htmlspecialchars($msg) ?></div>
  <?php endif; ?>

  <?php if ($listError): ?>
    <div class="alert alert-warning">
      Nie udało się pobrać listy obiektów: <?= htmlspecialchars($listError) ?>
    </div>
  <?php endif; ?>

  <div class="card mb-4">
    <div class="card-body">
      <h2 class="h5">Upload pliku (.tar.gz, max 100MB)</h2>

      <form method="post" enctype="multipart/form-data" class="row g-3">
        <div class="col-12">
          <label class="form-label">Plik</label>
          <input
            class="form-control"
            type="file"
            name="file"
            accept=".tar.gz,application/gzip,application/x-gzip"
            required
          >
          <div class="form-text">
            Przykład: <code>s3storemanager_1.0.0_coo_openshift.tar.gz</code>
          </div>
        </div>

        <div class="col-md-6">
          <label class="form-label">Autor</label>
          <select class="form-select" name="author" required>
            <option value="" selected disabled>Wybierz…</option>
            <?php foreach ($authors as $a): ?>
              <option value="<?= htmlspecialchars($a) ?>"><?= htmlspecialchars($a) ?></option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="col-md-6">
          <label class="form-label">Środowisko</label>
          <select class="form-select" name="environment" required>
            <option value="" selected disabled>Wybierz…</option>
            <?php foreach ($envs as $e): ?>
              <option value="<?= htmlspecialchars($e) ?>"><?= htmlspecialchars($e) ?></option>
            <?php endforeach; ?>
          </select>
        </div>

        <div class="col-12">
          <button class="btn btn-primary">Wyślij do S3 (MinIO)</button>
          <div class="form-text">
            Serwer sprawdza rozszerzenie <code>.tar.gz</code> oraz rozmiar ≤ 100MB i dopisuje tagi: nazwa, wersja, warstwa, archiwum, autor, srodowisko.
          </div>
        </div>
      </form>
    </div>
  </div>

  <h2 class="h5 mb-2">Pliki w bucketcie <code><?= htmlspecialchars($bucket) ?></code></h2>

  <div class="table-responsive">
    <table class="table table-sm align-middle">
      <thead>
        <tr>
          <th>nazwa</th>
          <th>wersja</th>
          <th>warstwa</th>
          <th>archiwum</th>
          <th>autor</th>
          <th>środowisko</th>
        </tr>
      </thead>
      <tbody>
      <?php if (!$rows): ?>
        <tr><td colspan="6" class="text-muted">Brak plików.</td></tr>
      <?php else: ?>
        <?php foreach ($rows as $r): ?>
          <tr>
            <td><?= htmlspecialchars($r['nazwa']) ?></td>
            <td><?= htmlspecialchars($r['wersja']) ?></td>
            <td><?= htmlspecialchars($r['warstwa']) ?></td>
            <td><code><?= htmlspecialchars($r['archiwum']) ?></code></td>
            <td><?= htmlspecialchars($r['autor']) ?></td>
            <td><?= htmlspecialchars($r['srodowisko']) ?></td>
          </tr>
        <?php endforeach; ?>
      <?php endif; ?>
      </tbody>
    </table>
  </div>

</body>
</html>