1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

氷河(Glacier)に眠っている写真たちをサルベージする 〜 その4 アーカイブの取得

Last updated at Posted at 2024-03-28

目的

AWS S3 Glacier内の各ボールドのボールドインベントリからアーカイブ一覧を参照し、アーカイブを取得する。

やり方

  • InitiateJobCommandを実行しアーカイブを要求する(ボールドインベントリ取得時と同一APIを使用するが指定する引数が異なる)

  • DescribeJobCommandを実行し進捗状況を取得する

  • アーカイブの取得が可能となったら、GetJobOutputCommandを実行しアーカイブを取得する

  • 取得したアーカイブは不要なので、DeleteArchiveCommandを実行しボールドからアーカイブを削除する

ソースコード

バックエンド

InitiateJobCommandを実行するinitiateArchiveJob API

src/app/api/initiateArchiveJob/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GlacierClient, InitiateJobCommand } from "@aws-sdk/client-glacier";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const params = await request.json();
  const vaultName = params["vaultName"];
  const number = params["number"];
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobParameters: { ArchiveId: string; SNSTopic: string; Type: string };
    vaultName: string;
  } = {
    accountId: "",
    jobParameters: {
      SNSTopic: "",
      Type: "",
      ArchiveId: "",
    },
    vaultName: "",
  };

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userVaults = db.collection("userVaults");
  const query = { VaultName: vaultName };
  const cursor = userVaults.find(query);
  let count = 0;

  let archiveIds = [];
  for await (const doc of cursor) {
    for (let archive of doc.ArchiveList) {
      archiveIds.push(archive.ArchiveId);
      count = count + 1;
      if (count >= Number(number)) {
        break;
      }
    }
  }

  input.accountId = "-";
  input["jobParameters"]["SNSTopic"] =
    "arn:aws:sns:<リージョンID>:<アカウントID>:gtool-sns";
  input["jobParameters"]["Type"] = "archive-retrieval";
  if (vaultName) {
    input["vaultName"] = vaultName;
  }
  for (let archiveId of archiveIds) {
    input["jobParameters"]["ArchiveId"] = archiveId;
    const command = new InitiateJobCommand(input);
    let response = await client.send(command);
    const userArchiveJobs = db.collection("userArchiveJobs");
    userArchiveJobs.insertOne({
      info: response,
      vauleName: vaultName,
      archiveId: archiveId,
    });
  }

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}

export async function OPTIONS() {
  return NextResponse.json({}, { headers: corsHeaders });
}

DescribeJobCommandを実行するdescribeArchiveJob API

src/app/api/describeArchiveJob/route.ts
import { NextRequest, NextResponse } from "next/server";
import { GlacierClient, DescribeJobCommand } from "@aws-sdk/client-glacier";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobId: string;
    vaultName: string;
  } = {
    accountId: "",
    jobId: "",
    vaultName: "",
  };

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userArchiveJobs = db.collection("userArchiveJobs");
  const cursor = userArchiveJobs.find();

  for await (const doc of cursor) {
    input.accountId = "-";
    input["jobId"] = doc.info.jobId;
    input["vaultName"] = doc.vauleName;
    const command = new DescribeJobCommand(input);
    let response = await client.send(command);

    const userVaults = db.collection("userArchiveJobs");
    const query = { "info.jobId": response.JobId };
    const update = {
      $set: {
        info: {
          Action: response.Action,
          Completed: response.Completed,
          CreationDate: response.CreationDate,
          jobId: response.JobId,
          SNSTopic: response.SNSTopic,
          StatusCode: response.StatusCode,
          VaultARN: response.VaultARN,
        },
      },
    };
    userVaults.updateOne(query, update);
  }

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}

GetJobOutputCommandとDeleteArchiveCommandを実行するgetArchiveJobOutput API

src/app/api/getArchiveJobOutput/route.ts
import { NextRequest, NextResponse } from "next/server";
import {
  GlacierClient,
  GetJobOutputCommand,
  DeleteArchiveCommand,
} from "@aws-sdk/client-glacier";
import { pipeline } from "stream/promises";
import fs from "fs";
import { MongoClient } from "mongodb";

export const corsHeaders = {
  "Access-Control-Allow-Origin": "http://localhost:3000",
  "Access-Control-Allow-Methods": "POST",
  "Access-Control-Allow-Headers": "Content-Type",
};

export async function POST(request: NextRequest): Promise<NextResponse> {
  const client = new GlacierClient({});
  let input: {
    accountId: string;
    jobId: string;
    range: string;
    vaultName: string;
  } = {
    accountId: "",
    jobId: "",
    range: "",
    vaultName: "",
  };

  let input2: {
    accountId: string;
    vaultName: string;
    archiveId: string;
  } = {
    accountId: "",
    vaultName: "",
    archiveId: "",
  };

  const dbclient = await MongoClient.connect(
    "mongodb://127.0.0.1/27017/glacier"
  );

  const db = dbclient.db("glacier");
  const userArchiveJobs = db.collection("userArchiveJobs");
  const cursor = userArchiveJobs.find();

  for await (const doc of cursor) {
    input.accountId = "-";
    input2.accountId = "-";
    input.jobId = doc.info.jobId;
    input["vaultName"] = doc.vauleName;
    input2["vaultName"] = doc.vauleName;
    const command = new GetJobOutputCommand(input);
    let response = await client.send(command);
    if (response.body) {
      await pipeline<any, any>(
        response.body,
        fs.createWriteStream(`${input.vaultName}/${input.jobId}`)
      );
      input2["archiveId"] = doc.archiveId;
      const command2 = new DeleteArchiveCommand(input2);
      let response2 = await client.send(command2);
    } else {
      console.error(`No body in response for job ${input.jobId}`);
      return NextResponse.json({}, { status: 400, headers: corsHeaders });
    }
  }

  return NextResponse.json({}, { status: 200, headers: corsHeaders });
}

フロントエンド

src/app/page.tsx
"use client";
import axios from "axios";
import { TextField, Button, Stack, Paper } from "@mui/material";
import { useForm, SubmitHandler } from "react-hook-form";

type Inputs = {
  inventoryVaultName: string;
+   archiveVaultName: string;
+   archiveNumber: string;
};

export default function Home() {
  const { register, handleSubmit } = useForm<Inputs>({
    defaultValues: {
      inventoryVaultName: "",
+       archiveVaultName: "",
+       archiveNumber: "",
    },
  });
  const listVaults = async () => {
    await axios.post("http://localhost:4000/api/listVaults");
  };
  const initiateInventoryJob: SubmitHandler<Inputs> = async (data) => {
    await axios.post("http://localhost:4000/api/initiateInventoryJob", {
      vaultName: data.inventoryVaultName,
    });
  };
  const describeInventoryJob = async () => {
    await axios.post("http://localhost:4000/api/describeInventoryJob");
  };
  const getInventoryJobOutput = async () => {
    await axios.post("http://localhost:4000/api/getInventoryJobOutput");
  };
+   const initiateArchiveJob: SubmitHandler<Inputs> = async (data) => {
+     await axios.post("http://localhost:4000/api/initiateArchiveJob", {
+       vaultName: data.archiveVaultName,
+       number: data.archiveNumber,
+     });
+   };
+   const describeArchiveJob = async () => {
+     await axios.post("http://localhost:4000/api/describeArchiveJob");
+   };
+   const getArchiveJobOutput = async () => {
+     await axios.post("http://localhost:4000/api/getArchiveJobOutput");
+   };
  return (
    <main className="flex min-h-screen flex-col items-center justify-between p-24">
      <div className="z-10 max-w-10xl w-full items-center justify-between font-mono text-sm lg:flex">
        <Stack spacing={2}>
          <Paper>
            <Button
              variant="outlined"
              onClick={listVaults}
              style={{
                textTransform: "none",
              }}
            >
              ボールド一覧を登録する
            </Button>
          </Paper>
          <Paper>
            <TextField
              {...register("inventoryVaultName")}
              label="ボールド名"
              variant="outlined"
            />
            <Button
              variant="outlined"
              onClick={handleSubmit(initiateInventoryJob)}
              style={{
                textTransform: "none",
              }}
            >
              ボールドインベントリを要求する
            </Button>
            <Button
              variant="outlined"
              onClick={describeInventoryJob}
              style={{
                textTransform: "none",
              }}
            >
              進捗状況を確認する
            </Button>
            <Button
              variant="outlined"
              onClick={getInventoryJobOutput}
              style={{
                textTransform: "none",
              }}
            >
              ボールドインベントリを登録する
            </Button>
          </Paper>
+          <Paper>
+            <TextField
+              {...register("archiveVaultName")}
+              label="ボールド名"
+              variant="outlined"
+            />
+            <TextField
+              {...register("archiveNumber")}
+              label="アーカイブ数"
+              variant="outlined"
+            />
+            <Button
+              variant="outlined"
+              onClick={handleSubmit(initiateArchiveJob)}
+              style={{
+                textTransform: "none",
+              }}
+            >
+              アーカイブを要求する
+            </Button>
+            <Button
+              variant="outlined"
+              onClick={describeArchiveJob}
+              style={{
+                textTransform: "none",
+              }}
+            >
+              進捗状況を確認する
+            </Button>
+            <Button
+              variant="outlined"
+              onClick={getArchiveJobOutput}
+              style={{
+                textTransform: "none",
+              }}
+            >
+              アーカイブを取得する
+            </Button>
+          </Paper>
        </Stack>
      </div>
    </main>
  );
}

実行結果

フロントエンド

ブラウザを開き、http://localhost:3000 にアクセスする。
Screenshot from 2024-03-28 19-18-34.png

アーカイブの要求

アーカイブを要求するボールド名と取得するアーカイブ数を入力後、[アーカイブを要求する]ボタンを押すと、アーカイブを取得するジョブの情報がMongoDBに登録される。
Screenshot from 2024-03-28 19-17-35.png

進捗状況の取得

[進捗状況を確認する]ボタンを押すと、アーカイブを取得するジョブの情報に進捗情報が追加される。
Screenshot from 2024-03-28 19-17-50.png

アーカイブの取得

アーカイブの要求後数時間待つと、アーカイブ取得の準備が整ったことを通知するメールが送信される。メールを受信後、バックエンド直下にボールド名の名前をつけたディレクトリを作成し、[アーカイブを取得する]ボタンを押すと、アーカイブが取得される。
Screenshot from 2024-03-28 19-18-50.png
取得したアーカイブは、ボールドから削除され、以降アーカイブを要求してもボールドに存在しないというエラーが返るようになる。

入力値に取得するアーカイブ数を設ける理由

アーカイブの取得は、アーカイブ取得の準備が整ってから24時間以内に行う必要があり、それを過ぎると取得できなくなり再度アーカイブを要求する必要がある。
通信回線の品質にもよるが、写真や動画のデータが万単位である場合、24時間以内にダウンロードが完了することはまずなく、せいぜい数千が限度である。当然、取得できなかった残りのデータに対しても、取り出し準備を行っているため課金が発生してしまう。
そのため、余計な課金が発生しないよう、あらかじめ要求するアーカイブ数を調整する必要があり、そのためにアーカイブ数を入力値として設けている。

今後の取り組み

ここまでで、アーカイブの取得に必要な最低限のプログラムを実装できたので、今後は、エラー処理の実装やフロントエンドの拡張、機能そのものの拡張に取り組んでいく。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?