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

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

## やり方
* InitiateJobCommandを実行しアーカイブを要求する（ボールドインベントリ取得時と同一APIを使用するが指定する引数が異なる）

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/InitiateJobCommand/

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

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/DescribeJobCommand/

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

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/GetJobOutputCommand/

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

https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/client/glacier/command/DeleteArchiveCommand/

# ソースコード
## バックエンド
### InitiateJobCommandを実行するinitiateArchiveJob API
```javascript: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
```javascript: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
```javascript: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 });
}
```
## フロントエンド
```diff_javascript: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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/d73b4f10-7183-8130-72d6-ce6f481e2d25.png)
## アーカイブの要求
アーカイブを要求するボールド名と取得するアーカイブ数を入力後、[アーカイブを要求する]ボタンを押すと、アーカイブを取得するジョブの情報がMongoDBに登録される。
![Screenshot from 2024-03-28 19-17-35.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/63ac8e93-c264-54db-2336-a19dcf9b314e.png)
## 進捗状況の取得
[進捗状況を確認する]ボタンを押すと、アーカイブを取得するジョブの情報に進捗情報が追加される。
![Screenshot from 2024-03-28 19-17-50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/2a5be665-324b-a031-f41d-209cf5ba0934.png)
## アーカイブの取得
アーカイブの要求後数時間待つと、アーカイブ取得の準備が整ったことを通知するメールが送信される。メールを受信後、バックエンド直下にボールド名の名前をつけたディレクトリを作成し、［アーカイブを取得する］ボタンを押すと、アーカイブが取得される。
![Screenshot from 2024-03-28 19-18-50.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3748326/d5056dd1-5ae3-e24a-5e43-8a2211a3b6bd.png)
取得したアーカイブは、ボールドから削除され、以降アーカイブを要求してもボールドに存在しないというエラーが返るようになる。

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

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